Compare commits
163
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27af7af6c5 | ||
|
|
7545f7392d | ||
|
|
94ddc8bedf | ||
|
|
c56948ebd0 | ||
|
|
6f5b5265b6 | ||
|
|
31d155fd88 | ||
|
|
a025525f47 | ||
|
|
e13fde36fb | ||
|
|
d829d584af | ||
|
|
ee84025322 | ||
|
|
bc9a7ef2cf | ||
|
|
badad181de | ||
|
|
85163b6e67 | ||
|
|
e8373a4cd1 | ||
|
|
e92b08025c | ||
|
|
62a09605cf | ||
|
|
47b316180d | ||
|
|
a4ba6169a6 | ||
|
|
a48fccc328 | ||
|
|
755c008a88 | ||
|
|
bbf9056dfa | ||
|
|
ecf97173b4 | ||
|
|
0f41b12ca0 | ||
|
|
fa698dd306 | ||
|
|
b50a7f080c | ||
|
|
2139f138ca | ||
|
|
8fe374fe25 | ||
|
|
ad2a851b94 | ||
|
|
721b730263 | ||
|
|
c2c01a9878 | ||
|
|
9fae801b7f | ||
|
|
2ae7f00acb | ||
|
|
041a9e26ad | ||
|
|
7f3fdfe64e | ||
|
|
0a7bdf7657 | ||
|
|
137dcaa101 | ||
|
|
a86de3fe5b | ||
|
|
165510341d | ||
|
|
b406738249 | ||
|
|
ffd728b061 | ||
|
|
e78b7adf28 | ||
|
|
a935273913 | ||
|
|
e162dc3013 | ||
|
|
e65ddad798 | ||
|
|
31a48e746d | ||
|
|
f9a8d64564 | ||
|
|
61534d3fb5 | ||
|
|
391f02f78a | ||
|
|
c8c56d8ea7 | ||
|
|
0443992dd4 | ||
|
|
9248332da6 | ||
|
|
b633a91a41 | ||
|
|
192a22c936 | ||
|
|
3f0ef70ecd | ||
|
|
5c618e0e41 | ||
|
|
be8a19b5c5 | ||
|
|
9d8bc5e2ab | ||
|
|
5b44c8154d | ||
|
|
61edac68a8 | ||
|
|
ef9a4e067a | ||
|
|
bd42306059 | ||
|
|
7c1830bcd5 | ||
|
|
e8d19d2bb7 | ||
|
|
b95cd149c6 | ||
|
|
e9c15fe40f | ||
|
|
1e72713ae2 | ||
|
|
0ace71eae6 | ||
|
|
696023b4ba | ||
|
|
ad72185a6c | ||
|
|
b499298299 | ||
|
|
fdb7cd3f6f | ||
|
|
ad64ba532f | ||
|
|
3728612e8c | ||
|
|
58a75da83f | ||
|
|
c482a99aca | ||
|
|
e7778b3518 | ||
|
|
76d5cff6fb | ||
|
|
1bc3c69de3 | ||
|
|
e0e8563224 | ||
|
|
a83239892c | ||
|
|
73148d76e1 | ||
|
|
902739b284 | ||
|
|
abd5d57fb9 | ||
|
|
8455599c3c | ||
|
|
20e9f4b056 | ||
|
|
595eda834f | ||
|
|
1b296daf42 | ||
|
|
c5e79ae3c9 | ||
|
|
bcc915d597 | ||
|
|
ca83dbd1a7 | ||
|
|
b8571d054b | ||
|
|
a8869e4545 | ||
|
|
eba85ba158 | ||
|
|
152f58a3c6 | ||
|
|
82a4542746 | ||
|
|
7173751f37 | ||
|
|
60b44a6637 | ||
|
|
e5544dde21 | ||
|
|
d824634859 | ||
|
|
1e08ccd937 | ||
|
|
e1ad15c620 | ||
|
|
2cf53280e4 | ||
|
|
24073d7f2a | ||
|
|
a80982a780 | ||
|
|
d159d9b281 | ||
|
|
09fd6b7190 | ||
|
|
89414107f9 | ||
|
|
884b12f759 | ||
|
|
3c0378358f | ||
|
|
70344ca06a | ||
|
|
01a3ec5ae2 | ||
|
|
20ef63007c | ||
|
|
f69c81f607 | ||
|
|
7652b4c0b9 | ||
|
|
b4ac9700c6 | ||
|
|
08e322e59f | ||
|
|
47a3fe9eae | ||
|
|
2056c1e352 | ||
|
|
33f674321b | ||
|
|
115976cacd | ||
|
|
d80464d285 | ||
|
|
c0669d2ed8 | ||
|
|
266d2d98d0 | ||
|
|
1e69b0e662 | ||
|
|
d638a9ed28 | ||
|
|
1dadc642d1 | ||
|
|
c4ae58831d | ||
|
|
21502ed023 | ||
|
|
619b3d61c7 | ||
|
|
d701464312 | ||
|
|
74a16d3646 | ||
|
|
824caddbf2 | ||
|
|
c75888833b | ||
|
|
3f1976815f | ||
|
|
d9bd5b2800 | ||
|
|
8fd800e69a | ||
|
|
ab41e67dc5 | ||
|
|
60b1bdb39f | ||
|
|
0aaa80a4ba | ||
|
|
ac36edf62d | ||
|
|
15c132a38b | ||
|
|
4c0c404a39 | ||
|
|
0555c929b1 | ||
|
|
03ac7f8557 | ||
|
|
757531bb63 | ||
|
|
1aa55f7a35 | ||
|
|
2594403503 | ||
|
|
5634d3ee78 | ||
|
|
0810775c07 | ||
|
|
b0d31ba51c | ||
|
|
2dffdbcd9f | ||
|
|
c9265e7903 | ||
|
|
a05f58f7fe | ||
|
|
93c8cae1e6 | ||
|
|
5a611bbc9b | ||
|
|
aadc35581d | ||
|
|
e61b0a36ac | ||
|
|
ae0a5064a4 | ||
|
|
cd13120937 | ||
|
|
61a410f98f | ||
|
|
e3501772c5 | ||
|
|
7863ce3e12 | ||
|
|
ea51ca89e7 |
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos",
|
"sourceRepo": "https://gitea.internal.quixos.org/quixos/quixos.git",
|
||||||
"sourceCommit": "a45fd9a85b541ce6b449b28e8b47954bf04de96b",
|
"sourceCommit": "473886a58fdacf15f2c873b77da96faf3be7fa65",
|
||||||
"sourcePath": "quixos-protocol",
|
"sourcePath": "quixos-protocol",
|
||||||
"exportName": "quixos-protocol",
|
"exportName": "quixos-protocol",
|
||||||
"mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
|
"mirrorRemote": "https://repos.quixos.org/quixos-toolchain/quixos-protocol.git"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
pkgs.jujutsu
|
pkgs.jujutsu
|
||||||
pkgs.gnutar
|
pkgs.gnutar
|
||||||
pkgs.util-linux
|
pkgs.util-linux
|
||||||
|
pkgs.nix
|
||||||
];
|
];
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
runHook preBuild
|
runHook preBuild
|
||||||
@@ -39,6 +40,8 @@
|
|||||||
export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$PWD/proto''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}"
|
export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$PWD/proto''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}"
|
||||||
patchShebangs node_modules/.bin node_modules/@bufbuild/protoc-gen-es/bin
|
patchShebangs node_modules/.bin node_modules/@bufbuild/protoc-gen-es/bin
|
||||||
yarn build
|
yarn build
|
||||||
|
esbuild dist/src/execution-world/index.js --bundle --platform=node --target=node24 --format=esm --outfile=execution-world.mjs
|
||||||
|
esbuild dist/src/react-build/package-queries.js --bundle --platform=node --target=node24 --format=esm --outfile=react-query-generator.mjs
|
||||||
esbuild dist/src/bindings/client.js --bundle --platform=node --target=node24 --format=esm --outfile=client-codegen.mjs
|
esbuild dist/src/bindings/client.js --bundle --platform=node --target=node24 --format=esm --outfile=client-codegen.mjs
|
||||||
diff --recursive --unified "$TMPDIR/generated-before/proto" src/gen
|
diff --recursive --unified "$TMPDIR/generated-before/proto" src/gen
|
||||||
diff --recursive --unified "$TMPDIR/generated-before/capability" src/capability-language/generated
|
diff --recursive --unified "$TMPDIR/generated-before/capability" src/capability-language/generated
|
||||||
@@ -59,28 +62,41 @@
|
|||||||
--bundle --platform=node --target=node24 --format=esm \
|
--bundle --platform=node --target=node24 --format=esm \
|
||||||
--outfile=quixos-lock-check.mjs
|
--outfile=quixos-lock-check.mjs
|
||||||
esbuild dist/src/bindings/cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-codegen-ts.mjs
|
esbuild dist/src/bindings/cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-codegen-ts.mjs
|
||||||
|
esbuild dist/src/bindings/portable-client-cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-codegen-client.mjs
|
||||||
esbuild dist/src/bindings/react-platform.js --bundle --platform=node --target=node24 --format=esm --outfile=react-platform.mjs
|
esbuild dist/src/bindings/react-platform.js --bundle --platform=node --target=node24 --format=esm --outfile=react-platform.mjs
|
||||||
esbuild dist/src/capability-language/tool-cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-qx.mjs
|
esbuild dist/src/capability-language/tool-cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-qx.mjs
|
||||||
|
esbuild dist/src/capability-language/source-check-driver.js --bundle --platform=node --target=node24 --format=esm --outfile=source-check-driver.mjs
|
||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
doCheck = true;
|
doCheck = true;
|
||||||
checkPhase = ''
|
checkPhase = ''
|
||||||
runHook preCheck
|
runHook preCheck
|
||||||
yarn test
|
yarn test
|
||||||
|
node dist/test/emit-portable-reader.js
|
||||||
|
node dist/test/emit-managed.js
|
||||||
runHook postCheck
|
runHook postCheck
|
||||||
'';
|
'';
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
mkdir -p "$out"
|
mkdir -p "$out"
|
||||||
install -Dm644 nix/checked-package.nix "$out/share/checked-package.nix"
|
install -Dm644 nix/checked-package.nix "$out/share/checked-package.nix"
|
||||||
|
install -Dm644 nix/checked-artifacts.nix "$out/share/checked-artifacts.nix"
|
||||||
|
install -Dm644 nix/package-context.nix "$out/share/package-context.nix"
|
||||||
|
install -Dm644 nix/lock-source.nix "$out/share/lock-source.nix"
|
||||||
|
install -Dm644 nix/package-context.mjs "$out/share/package-context.mjs"
|
||||||
|
install -Dm644 nix/check-package.mjs "$out/share/check-package.mjs"
|
||||||
|
install -Dm644 nix/check-execution.mjs "$out/share/check-execution.mjs"
|
||||||
substitute nix/checked-candidate.nix "$out/share/checked-candidate.nix" \
|
substitute nix/checked-candidate.nix "$out/share/checked-candidate.nix" \
|
||||||
--replace-fail '@nixpkgs@' '${pkgs.path}'
|
--replace-fail '@nixpkgs@' '${pkgs.path}' \
|
||||||
|
--replace-fail '@nixpkgsNarHash@' '${nixpkgs.narHash}'
|
||||||
cp --reflink=auto --recursive grammar "$out/grammar"
|
cp --reflink=auto --recursive grammar "$out/grammar"
|
||||||
cp --reflink=auto --recursive proto "$out/proto"
|
cp --reflink=auto --recursive proto "$out/proto"
|
||||||
cp --reflink=auto --recursive dist "$out/dist"
|
cp --reflink=auto --recursive dist "$out/dist"
|
||||||
cp package.json "$out/package.json"
|
cp package.json "$out/package.json"
|
||||||
|
mkdir -p "$out/share/quixos-protocol/qualification"
|
||||||
|
cp -r dist/portable-reader dist/managed "$out/share/quixos-protocol/qualification/"
|
||||||
mkdir -p "$out/bin"
|
mkdir -p "$out/bin"
|
||||||
for tool in quixos-codegen-ts quixos-qx; do
|
for tool in quixos-codegen-ts quixos-codegen-client quixos-qx; do
|
||||||
printf '#!/bin/sh\nexec ${pkgs.nodejs_24}/bin/node "%s/libexec/quixos-protocol/%s.mjs" "$@"\n' "$out" "$tool" > "$out/bin/$tool"
|
printf '#!/bin/sh\nexec ${pkgs.nodejs_24}/bin/node "%s/libexec/quixos-protocol/%s.mjs" "$@"\n' "$out" "$tool" > "$out/bin/$tool"
|
||||||
chmod +x "$out/bin/$tool"
|
chmod +x "$out/bin/$tool"
|
||||||
done
|
done
|
||||||
@@ -112,9 +128,13 @@
|
|||||||
EOF
|
EOF
|
||||||
chmod +x "$out/bin/quixos-lock-check"
|
chmod +x "$out/bin/quixos-lock-check"
|
||||||
mkdir -p "$out/libexec/quixos-protocol"
|
mkdir -p "$out/libexec/quixos-protocol"
|
||||||
|
install -m644 execution-world.mjs "$out/libexec/quixos-protocol/execution-world.mjs"
|
||||||
|
install -m644 react-query-generator.mjs "$out/libexec/quixos-protocol/react-query-generator.mjs"
|
||||||
install -m644 client-codegen.mjs "$out/libexec/quixos-protocol/client-codegen.mjs"
|
install -m644 client-codegen.mjs "$out/libexec/quixos-protocol/client-codegen.mjs"
|
||||||
install -m644 react-platform.mjs "$out/libexec/quixos-protocol/react-platform.mjs"
|
install -m644 react-platform.mjs "$out/libexec/quixos-protocol/react-platform.mjs"
|
||||||
install -m644 quixos-codegen-ts.mjs quixos-qx.mjs "$out/libexec/quixos-protocol/"
|
install -m644 quixos-codegen-ts.mjs quixos-qx.mjs "$out/libexec/quixos-protocol/"
|
||||||
|
install -m644 source-check-driver.mjs "$out/libexec/quixos-protocol/"
|
||||||
|
install -m644 quixos-codegen-client.mjs "$out/libexec/quixos-protocol/"
|
||||||
install -m644 quixos-descriptor-check.mjs "$out/libexec/quixos-protocol/quixos-descriptor-check.mjs"
|
install -m644 quixos-descriptor-check.mjs "$out/libexec/quixos-protocol/quixos-descriptor-check.mjs"
|
||||||
install -m644 quixos-capability-compile.mjs "$out/libexec/quixos-protocol/quixos-capability-compile.mjs"
|
install -m644 quixos-capability-compile.mjs "$out/libexec/quixos-protocol/quixos-capability-compile.mjs"
|
||||||
install -m644 quixos-workspace-compile.mjs "$out/libexec/quixos-protocol/quixos-workspace-compile.mjs"
|
install -m644 quixos-workspace-compile.mjs "$out/libexec/quixos-protocol/quixos-workspace-compile.mjs"
|
||||||
|
|||||||
+217
-21
@@ -27,6 +27,7 @@ workspaceItem
|
|||||||
| sharedAttachmentDecl
|
| sharedAttachmentDecl
|
||||||
| conformanceDecl
|
| conformanceDecl
|
||||||
| constructorBindingDecl
|
| constructorBindingDecl
|
||||||
|
| presentationDecl
|
||||||
| typeAliasDecl
|
| typeAliasDecl
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -43,10 +44,15 @@ externalInterfaceDecl
|
|||||||
: EXTERNAL INTERFACE identifier REVISION stringLiteral SEMI
|
: EXTERNAL INTERFACE identifier REVISION stringLiteral SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
|
symbolDecl
|
||||||
|
: SYMBOL identifier ID stringLiteral SEMI
|
||||||
|
;
|
||||||
|
|
||||||
resourcePreamble
|
resourcePreamble
|
||||||
: resourceImportDecl
|
: resourceImportDecl
|
||||||
| externalAtomDecl
|
| externalAtomDecl
|
||||||
| externalInterfaceDecl
|
| externalInterfaceDecl
|
||||||
|
| symbolDecl
|
||||||
| typeAliasDecl
|
| typeAliasDecl
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -92,33 +98,43 @@ interfaceMember
|
|||||||
: valueMember
|
: valueMember
|
||||||
| relationshipMember
|
| relationshipMember
|
||||||
| operationMember
|
| operationMember
|
||||||
|
| moduleMember
|
||||||
|
;
|
||||||
|
|
||||||
|
moduleMember
|
||||||
|
: MODULE identifier ID stringLiteral CONTRACT stringLiteral (SUBJECT targetConstraint)? SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
operationMember
|
operationMember
|
||||||
: STATIC? OPERATION identifier ID stringLiteral COLON valueType ARROW valueType
|
: STATIC? OPERATION identifier ID stringLiteral COLON valueType ARROW valueType
|
||||||
LBRACE CALL ID stringLiteral SEMI RBRACE
|
LBRACE CALL ID stringLiteral executionClause? SEMI RBRACE
|
||||||
;
|
;
|
||||||
|
|
||||||
valueMember
|
valueMember
|
||||||
: VALUE identifier ID stringLiteral COLON valueType
|
: queryReadContract? (VALUE | FIELD) identifier ID stringLiteral COLON valueType
|
||||||
LBRACE valueMemberOperation* RBRACE
|
LBRACE valueMemberOperation* RBRACE
|
||||||
;
|
;
|
||||||
|
|
||||||
valueMemberOperation
|
valueMemberOperation
|
||||||
: GET ID stringLiteral SEMI
|
: GET ID stringLiteral executionClause? SEMI
|
||||||
| SET ID stringLiteral SEMI
|
| SET ID stringLiteral executionClause? SEMI
|
||||||
|
| EDIT ID stringLiteral executionClause? SEMI
|
||||||
| WATCH START ID stringLiteral STOP ID stringLiteral SEMI
|
| WATCH START ID stringLiteral STOP ID stringLiteral SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
relationshipMember
|
relationshipMember
|
||||||
: RELATION identifier ID stringLiteral COLON cardinality targetConstraint ORDERED?
|
: queryReadContract? RELATION identifier ID stringLiteral COLON cardinality targetConstraint ORDERED? (KEYED stringLiteral)?
|
||||||
LBRACE relationshipOperation* RBRACE
|
LBRACE relationshipOperation* RBRACE
|
||||||
;
|
;
|
||||||
|
|
||||||
|
queryReadContract
|
||||||
|
: QUERYABLE (RPC | PORTABLE)?
|
||||||
|
;
|
||||||
|
|
||||||
relationshipOperation
|
relationshipOperation
|
||||||
: RESOLVE ID stringLiteral SEMI
|
: RESOLVE ID stringLiteral executionClause? SEMI
|
||||||
| CONNECT ID stringLiteral SEMI
|
| CONNECT ID stringLiteral executionClause? SEMI
|
||||||
| DISCONNECT ID stringLiteral SEMI
|
| DISCONNECT ID stringLiteral executionClause? SEMI
|
||||||
| WATCH START ID stringLiteral STOP ID stringLiteral SEMI
|
| WATCH START ID stringLiteral STOP ID stringLiteral SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -138,16 +154,69 @@ packageExport
|
|||||||
: packageOperationExport
|
: packageOperationExport
|
||||||
| packageFunctionExport
|
| packageFunctionExport
|
||||||
| packageConstructorExport
|
| packageConstructorExport
|
||||||
|
| packageQuery
|
||||||
|
| packageQuerySpecialization
|
||||||
|
| moduleExport
|
||||||
;
|
;
|
||||||
|
|
||||||
|
moduleExport
|
||||||
|
: MODULE identifier ID stringLiteral IMPLEMENTS interfaceType DOT identifier SEMI
|
||||||
|
;
|
||||||
|
|
||||||
|
presentationDecl
|
||||||
|
: PRESENTATION identifier ID stringLiteral FOR interfaceType USING identifier DOT identifier SEMI
|
||||||
|
;
|
||||||
|
|
||||||
|
packageQuery
|
||||||
|
: QUERY FRAGMENT? identifier typeParameters? ID stringLiteral ROOT WORKSPACE? interfaceType
|
||||||
|
DOCUMENT stringLiteral
|
||||||
|
(OPERATION stringLiteral LBRACE queryClause* RBRACE
|
||||||
|
| FRAGMENT stringLiteral (OPERATION stringLiteral)? (LBRACE queryClause* RBRACE | SEMI))
|
||||||
|
;
|
||||||
|
|
||||||
|
packageQuerySpecialization
|
||||||
|
: QUERY identifier ID stringLiteral SPECIALIZE identifier typeArguments SEMI
|
||||||
|
;
|
||||||
|
|
||||||
|
queryClause
|
||||||
|
: REPLICA SEMI
|
||||||
|
| LOCAL FROM identifier SEMI
|
||||||
|
| FRAGMENTS stringLiteral SEMI
|
||||||
|
| FRAGMENTS identifier DOT identifier (WITH LBRACE fragmentVariable* RBRACE)? SEMI
|
||||||
|
| VIEW OBJECT? identifier AS interfaceType SEMI
|
||||||
|
| MAX identifier INTEGER SEMI
|
||||||
|
| ALLOW interfaceType DOT identifier identifier stringLiteral SEMI
|
||||||
|
| WATCH SEMI
|
||||||
|
| POLL INTEGER stringLiteral SEMI
|
||||||
|
;
|
||||||
|
|
||||||
|
fragmentVariable : identifier TO identifier SEMI;
|
||||||
|
|
||||||
packageOperationExport
|
packageOperationExport
|
||||||
: OPERATION identifier typeParameters? ID stringLiteral COLON valueType ARROW valueType
|
: OPERATION identifier typeParameters? ID stringLiteral COLON valueType ARROW valueType
|
||||||
MODE operationMode eventClause? RECEIVER receiverRequirement dependencyBlock? SEMI
|
MODE operationMode executionClause? eventClause? RECEIVER receiverRequirement dependencyBlock? preparationClause? acceptanceClause? SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
packageFunctionExport
|
packageFunctionExport
|
||||||
: FUNCTION identifier typeParameters? ID stringLiteral COLON valueType ARROW valueType
|
: FUNCTION identifier typeParameters? ID stringLiteral COLON valueType ARROW valueType
|
||||||
dependencyBlock? SEMI
|
executionClause? dependencyBlock? preparationClause? acceptanceClause? SEMI
|
||||||
|
;
|
||||||
|
|
||||||
|
acceptanceClause
|
||||||
|
: ACCEPT USING identifier
|
||||||
|
;
|
||||||
|
|
||||||
|
preparationClause
|
||||||
|
: PREPARES CREATION identifier USING stringLiteral
|
||||||
|
;
|
||||||
|
|
||||||
|
executionClause
|
||||||
|
: EXECUTION REMOTE
|
||||||
|
| EXECUTION (SNAPSHOT | TRANSACTION) (TARGETS LBRACK identifierList RBRACK)?
|
||||||
|
;
|
||||||
|
|
||||||
|
selectedOperations
|
||||||
|
: USING LBRACK memberOperationRef (COMMA memberOperationRef)* RBRACK
|
||||||
;
|
;
|
||||||
|
|
||||||
packageConstructorExport
|
packageConstructorExport
|
||||||
@@ -183,10 +252,13 @@ dependencyBlock
|
|||||||
;
|
;
|
||||||
|
|
||||||
dependencyPort
|
dependencyPort
|
||||||
: STATE identifier ID stringLiteral COLON valueType primitiveList SEMI
|
: REFLECTION identifier ID stringLiteral LBRACK identifierList RBRACK SEMI
|
||||||
| EDGE identifier ID stringLiteral COLON cardinality targetConstraint primitiveList SEMI
|
| STATE identifier ID stringLiteral COLON valueType primitiveList SEMI
|
||||||
| INTERFACE identifier ID stringLiteral COLON identifier typeArguments? SEMI
|
| EDGE identifier ID stringLiteral COLON cardinality targetConstraint (KEYED stringLiteral)? primitiveList SEMI
|
||||||
|
| INTERFACE identifier ID stringLiteral COLON identifier typeArguments? selectedOperations? (FROM INPUT)? SEMI
|
||||||
|
| CREATION identifier ID stringLiteral COLON identifier INPUT valueType SEMI
|
||||||
| CONSTRUCTOR identifier ID stringLiteral COLON identifier (INPUT valueType)? SEMI
|
| CONSTRUCTOR identifier ID stringLiteral COLON identifier (INPUT valueType)? SEMI
|
||||||
|
| QUERY identifier ID stringLiteral COLON identifier DOT identifier SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
primitiveList
|
primitiveList
|
||||||
@@ -196,6 +268,7 @@ primitiveList
|
|||||||
primitive
|
primitive
|
||||||
: READ
|
: READ
|
||||||
| WRITE
|
| WRITE
|
||||||
|
| EDIT
|
||||||
| RESOLVE
|
| RESOLVE
|
||||||
| CONNECT
|
| CONNECT
|
||||||
| DISCONNECT
|
| DISCONNECT
|
||||||
@@ -218,8 +291,10 @@ stateDecl
|
|||||||
;
|
;
|
||||||
|
|
||||||
storagePolicy
|
storagePolicy
|
||||||
: OPTIMISTIC_REGISTER
|
: REGISTER
|
||||||
| CRDT LPAREN valueType RPAREN
|
| CRDT LPAREN valueType RPAREN
|
||||||
|
| JOURNAL jsonObject
|
||||||
|
| SUBSCRIPTION LPAREN valueType RPAREN
|
||||||
;
|
;
|
||||||
|
|
||||||
edgeDecl
|
edgeDecl
|
||||||
@@ -232,15 +307,38 @@ edgeEndpoint
|
|||||||
;
|
;
|
||||||
|
|
||||||
conformanceDecl
|
conformanceDecl
|
||||||
: CONFORM identifier AS identifier typeArguments? (ID stringLiteral)? (SEMANTIC_MAJOR INTEGER)?
|
: CONFORM typeParameters? identifier conformanceSourceArguments? AS identifier typeArguments? (ID stringLiteral)? (SEMANTIC_MAJOR INTEGER)?
|
||||||
LBRACE conformanceItem* RBRACE
|
LBRACE conformanceItem* RBRACE
|
||||||
;
|
;
|
||||||
|
|
||||||
|
conformanceSourceArguments
|
||||||
|
: typeArguments
|
||||||
|
;
|
||||||
|
|
||||||
conformanceItem
|
conformanceItem
|
||||||
: PRIVATE attachmentDecl
|
: PRIVATE attachmentDecl
|
||||||
| operationBindingDecl
|
| operationBindingDecl
|
||||||
| stateFieldBindingDecl
|
| stateFieldBindingDecl
|
||||||
|
| initializationDecl
|
||||||
| relationshipMaterializationDecl
|
| relationshipMaterializationDecl
|
||||||
|
| moduleBindingDecl
|
||||||
|
;
|
||||||
|
|
||||||
|
moduleBindingDecl
|
||||||
|
: BIND identifier TO identifier DOT identifier SEMI
|
||||||
|
;
|
||||||
|
|
||||||
|
initializationDecl
|
||||||
|
: INITIALIZE LBRACE initializationField* RBRACE SEMI
|
||||||
|
;
|
||||||
|
|
||||||
|
initializationField
|
||||||
|
: identifier TO STATE identifier SEMI
|
||||||
|
| identifier TO EDGE identifier DOT identifier SEMI
|
||||||
|
;
|
||||||
|
|
||||||
|
creationOwnerMapping
|
||||||
|
: interfaceType FROM INPUT (DOT identifier)* SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
stateFieldBindingDecl
|
stateFieldBindingDecl
|
||||||
@@ -252,7 +350,7 @@ relationshipMaterializationDecl
|
|||||||
;
|
;
|
||||||
|
|
||||||
operationBindingDecl
|
operationBindingDecl
|
||||||
: BIND memberOperationRef TO operationProvider SEMI
|
: BIND memberOperationRef TO operationProvider (QUERY_REASON stringLiteral)? SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
memberOperationRef
|
memberOperationRef
|
||||||
@@ -264,6 +362,7 @@ operationName
|
|||||||
| CALL
|
| CALL
|
||||||
| GET
|
| GET
|
||||||
| SET
|
| SET
|
||||||
|
| EDIT
|
||||||
| RESOLVE
|
| RESOLVE
|
||||||
| CONNECT
|
| CONNECT
|
||||||
| DISCONNECT
|
| DISCONNECT
|
||||||
@@ -274,14 +373,23 @@ operationName
|
|||||||
;
|
;
|
||||||
|
|
||||||
operationProvider
|
operationProvider
|
||||||
: STATE identifier DOT statePrimitive
|
: LIFECYCLE DOT identifier
|
||||||
|
| JOURNAL identifier DOT identifier
|
||||||
|
| SUBSCRIPTION identifier DOT identifier
|
||||||
|
| STATE identifier DOT statePrimitive
|
||||||
| EDGE identifier DOT identifier DOT edgePrimitive
|
| EDGE identifier DOT identifier DOT edgePrimitive
|
||||||
| PACKAGE identifier DOT identifier typeArguments? dependencyBindingBlock?
|
| INTERFACE identifier typeArguments? DOT memberOperationRef
|
||||||
|
| PACKAGE identifier DOT identifier typeArguments? dependencyBindingBlock? acceptanceBinding?
|
||||||
|
;
|
||||||
|
|
||||||
|
acceptanceBinding
|
||||||
|
: ACCEPT dependencyBindingBlock
|
||||||
;
|
;
|
||||||
|
|
||||||
statePrimitive
|
statePrimitive
|
||||||
: READ
|
: READ
|
||||||
| WRITE
|
| WRITE
|
||||||
|
| EDIT
|
||||||
| WATCH_START
|
| WATCH_START
|
||||||
| WATCH_STOP
|
| WATCH_STOP
|
||||||
;
|
;
|
||||||
@@ -299,10 +407,13 @@ dependencyBindingBlock
|
|||||||
;
|
;
|
||||||
|
|
||||||
dependencyBinding
|
dependencyBinding
|
||||||
: identifier TO STATE identifier (VIA EDGE identifier DOT identifier)? SEMI
|
: identifier TO WORKSPACE REFLECTION LBRACK identifierList RBRACK SEMI
|
||||||
|
| identifier TO STATE identifier (VIA EDGE identifier DOT identifier)? SEMI
|
||||||
| identifier TO EDGE identifier DOT identifier (VIA EDGE identifier DOT identifier)? SEMI
|
| identifier TO EDGE identifier DOT identifier (VIA EDGE identifier DOT identifier)? SEMI
|
||||||
| identifier TO INTERFACE identifier typeArguments? (VIA EDGE identifier DOT identifier)? SEMI
|
| identifier TO INTERFACE identifier typeArguments? ((VIA EDGE identifier DOT identifier) | (FROM INPUT))? SEMI
|
||||||
|
| identifier TO CREATION identifier WITH LBRACE creationOwnerMapping* RBRACE SEMI
|
||||||
| identifier TO CONSTRUCTOR identifier SEMI
|
| identifier TO CONSTRUCTOR identifier SEMI
|
||||||
|
| identifier TO WORKSPACE? QUERY identifier DOT identifier (VIA EDGE identifier DOT identifier)? SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
constructorBindingDecl
|
constructorBindingDecl
|
||||||
@@ -320,6 +431,7 @@ valueType
|
|||||||
| OPTIONAL LT valueType GT
|
| OPTIONAL LT valueType GT
|
||||||
| LIST LT valueType GT
|
| LIST LT valueType GT
|
||||||
| RECORD LBRACE recordField* RBRACE
|
| RECORD LBRACE recordField* RBRACE
|
||||||
|
| VARIANT ID stringLiteral LBRACE variantCase+ RBRACE
|
||||||
| identifier typeArguments?
|
| identifier typeArguments?
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -327,6 +439,10 @@ recordField
|
|||||||
: identifier COLON valueType SEMI
|
: identifier COLON valueType SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
|
variantCase
|
||||||
|
: identifier ID stringLiteral COLON valueType SEMI
|
||||||
|
;
|
||||||
|
|
||||||
scalarType
|
scalarType
|
||||||
: BOOL
|
: BOOL
|
||||||
| BYTES
|
| BYTES
|
||||||
@@ -370,14 +486,88 @@ jsonArray
|
|||||||
|
|
||||||
identifier
|
identifier
|
||||||
: IDENTIFIER
|
: IDENTIFIER
|
||||||
|
| START
|
||||||
|
| STOP
|
||||||
|
| EDIT
|
||||||
|
| CREATION
|
||||||
|
| INITIALIZE
|
||||||
|
| EXECUTION
|
||||||
|
| SNAPSHOT
|
||||||
|
| TRANSACTION
|
||||||
|
| REMOTE
|
||||||
|
| TARGETS
|
||||||
|
| PORTABLE
|
||||||
|
| REPLICA
|
||||||
|
| LOCAL
|
||||||
|
| FROM
|
||||||
|
| REGISTER
|
||||||
|
| INTERFACES
|
||||||
|
| REFLECTION
|
||||||
| SOURCE
|
| SOURCE
|
||||||
|
| QUERY
|
||||||
|
| SPECIALIZE
|
||||||
|
| ROOT
|
||||||
|
| DOCUMENT
|
||||||
|
| FRAGMENTS
|
||||||
|
| FRAGMENT
|
||||||
|
| VIEW
|
||||||
|
| MAX
|
||||||
|
| ALLOW
|
||||||
|
| POLL
|
||||||
|
| RPC
|
||||||
|
| QUERYABLE
|
||||||
|
| RESULT_BYTES
|
||||||
|
| RPC_CALLS
|
||||||
|
| DEADLINE_MS
|
||||||
|
| PRESENTATION
|
||||||
|
| SUBJECT
|
||||||
|
| MESSAGE
|
||||||
|
| JOURNAL
|
||||||
|
| SUBSCRIPTION
|
||||||
|
| CONNECT
|
||||||
|
| FOR
|
||||||
;
|
;
|
||||||
|
|
||||||
stringLiteral
|
stringLiteral
|
||||||
: STRING_LITERAL
|
: STRING_LITERAL
|
||||||
;
|
;
|
||||||
|
|
||||||
|
EDIT: 'edit';
|
||||||
|
MODULE: 'module';
|
||||||
|
CONTRACT: 'contract';
|
||||||
|
PRESENTATION: 'presentation';
|
||||||
|
SUBJECT: 'subject';
|
||||||
|
FOR: 'for';
|
||||||
|
CREATION: 'creation';
|
||||||
|
VARIANT: 'variant';
|
||||||
|
PREPARES: 'prepares';
|
||||||
|
INITIALIZE: 'initialize';
|
||||||
WORKSPACE: 'workspace';
|
WORKSPACE: 'workspace';
|
||||||
|
EXECUTION: 'execution';
|
||||||
|
SNAPSHOT: 'snapshot';
|
||||||
|
TRANSACTION: 'transaction';
|
||||||
|
REMOTE: 'remote';
|
||||||
|
TARGETS: 'targets';
|
||||||
|
PORTABLE: 'portable';
|
||||||
|
REPLICA: 'replica';
|
||||||
|
LOCAL: 'local';
|
||||||
|
FROM: 'from';
|
||||||
|
RESULT_BYTES: 'result-bytes';
|
||||||
|
RPC_CALLS: 'rpc-calls';
|
||||||
|
DEADLINE_MS: 'deadline-ms';
|
||||||
|
QUERY: 'query';
|
||||||
|
REFLECTION: 'reflection';
|
||||||
|
SPECIALIZE: 'specialize';
|
||||||
|
ROOT: 'root';
|
||||||
|
DOCUMENT: 'document';
|
||||||
|
FRAGMENTS: 'fragments';
|
||||||
|
VIEW: 'view';
|
||||||
|
MAX: 'max';
|
||||||
|
ALLOW: 'allow';
|
||||||
|
POLL: 'poll';
|
||||||
|
QUERYABLE: 'queryable';
|
||||||
|
RPC: 'rpc';
|
||||||
|
QUERY_REASON: 'query-reason';
|
||||||
TYPE: 'type';
|
TYPE: 'type';
|
||||||
OBJECT: 'object';
|
OBJECT: 'object';
|
||||||
STORABLE: 'storable';
|
STORABLE: 'storable';
|
||||||
@@ -386,11 +576,13 @@ REF: 'ref';
|
|||||||
FRAGMENT: 'fragment';
|
FRAGMENT: 'fragment';
|
||||||
IMPORT: 'import';
|
IMPORT: 'import';
|
||||||
EXTERNAL: 'external';
|
EXTERNAL: 'external';
|
||||||
|
SYMBOL: 'symbol';
|
||||||
ATOM: 'atom';
|
ATOM: 'atom';
|
||||||
INTERFACE: 'interface';
|
INTERFACE: 'interface';
|
||||||
INTERFACES: 'interfaces';
|
INTERFACES: 'interfaces';
|
||||||
PACKAGE: 'package';
|
PACKAGE: 'package';
|
||||||
VALUE: 'value';
|
VALUE: 'value';
|
||||||
|
FIELD: 'field';
|
||||||
RELATION: 'relation';
|
RELATION: 'relation';
|
||||||
OPERATION: 'operation';
|
OPERATION: 'operation';
|
||||||
FUNCTION: 'function';
|
FUNCTION: 'function';
|
||||||
@@ -409,6 +601,7 @@ EDGE: 'edge';
|
|||||||
PROJECTION: 'projection';
|
PROJECTION: 'projection';
|
||||||
WITH: 'with';
|
WITH: 'with';
|
||||||
USING: 'using';
|
USING: 'using';
|
||||||
|
ACCEPT: 'accept';
|
||||||
VIA: 'via';
|
VIA: 'via';
|
||||||
MATERIALIZE: 'materialize';
|
MATERIALIZE: 'materialize';
|
||||||
IF: 'if';
|
IF: 'if';
|
||||||
@@ -422,6 +615,9 @@ COMMIT: 'commit';
|
|||||||
REVISION: 'revision';
|
REVISION: 'revision';
|
||||||
SEMANTIC_MAJOR: 'semantic-major';
|
SEMANTIC_MAJOR: 'semantic-major';
|
||||||
ON_DELETE: 'on-delete';
|
ON_DELETE: 'on-delete';
|
||||||
|
LIFECYCLE: 'lifecycle';
|
||||||
|
JOURNAL: 'journal';
|
||||||
|
SUBSCRIPTION: 'subscription';
|
||||||
RETAIN_OTHER: 'retain-other';
|
RETAIN_OTHER: 'retain-other';
|
||||||
KEYED: 'keyed';
|
KEYED: 'keyed';
|
||||||
PUBLIC_TRAVERSAL: 'public-traversal';
|
PUBLIC_TRAVERSAL: 'public-traversal';
|
||||||
@@ -447,7 +643,7 @@ WATCH_START: 'watch-start';
|
|||||||
WATCH_STOP: 'watch-stop';
|
WATCH_STOP: 'watch-stop';
|
||||||
SUBSCRIBE: 'subscribe';
|
SUBSCRIBE: 'subscribe';
|
||||||
UNSUBSCRIBE: 'unsubscribe';
|
UNSUBSCRIBE: 'unsubscribe';
|
||||||
OPTIMISTIC_REGISTER: 'optimistic-register';
|
REGISTER: 'register';
|
||||||
CRDT: 'crdt';
|
CRDT: 'crdt';
|
||||||
OPTIONAL_ONE: 'optional-one';
|
OPTIONAL_ONE: 'optional-one';
|
||||||
EXACTLY_ONE: 'exactly-one';
|
EXACTLY_ONE: 'exactly-one';
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ importEntry
|
|||||||
resourceKind
|
resourceKind
|
||||||
: INTERFACE
|
: INTERFACE
|
||||||
| PACKAGE
|
| PACKAGE
|
||||||
|
| BUILD
|
||||||
;
|
;
|
||||||
|
|
||||||
sourceBlock
|
sourceBlock
|
||||||
@@ -58,6 +59,7 @@ QUIXOS: 'quixos';
|
|||||||
SOURCE: 'source';
|
SOURCE: 'source';
|
||||||
INTERFACE: 'interface';
|
INTERFACE: 'interface';
|
||||||
PACKAGE: 'package';
|
PACKAGE: 'package';
|
||||||
|
BUILD: 'build';
|
||||||
REPOSITORY: 'repository';
|
REPOSITORY: 'repository';
|
||||||
COMMIT: 'commit';
|
COMMIT: 'commit';
|
||||||
POLICY: 'policy';
|
POLICY: 'policy';
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Validate compiler-owned plans and immutable artifact receipts before publishing check outputs.
|
||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { join } from "node:path";
|
||||||
|
const [compilerPath, planPath, checksPath, output] = process.argv.slice(2);
|
||||||
|
const compiler = await import(pathToFileURL(compilerPath));
|
||||||
|
const plan = JSON.parse(await readFile(planPath, "utf8"));
|
||||||
|
const checks = JSON.parse(await readFile(checksPath, "utf8"));
|
||||||
|
compiler.compileWorkspaceEntry(plan, JSON.parse(await readFile(join(output, "entry.json"), "utf8")));
|
||||||
|
const roots = Object.create(null);
|
||||||
|
for (const check of checks) {
|
||||||
|
if (Object.hasOwn(roots, check.packageRevisionId)) throw Error("Duplicate checked package revision");
|
||||||
|
roots[check.packageRevisionId] = check.artifactPath;
|
||||||
|
}
|
||||||
|
const generator = createHash("sha256")
|
||||||
|
.update(await readFile(compilerPath))
|
||||||
|
.digest("hex");
|
||||||
|
// These compiler-dependent checks belong to the Nix build, not runtime recovery.
|
||||||
|
const candidate = JSON.parse(await readFile(join(output, "candidate.json"), "utf8"));
|
||||||
|
const { executionContracts: _legacyContracts, ...workspace } = candidate;
|
||||||
|
if (compiler.hexDigest(compiler.compileExecutionPlan(workspace)) !== compiler.hexDigest(plan))
|
||||||
|
throw Error("CANDIDATE_PLAN_MISMATCH: build plan differs from source compilation");
|
||||||
|
const checked = await compiler.loadCheckedArtifactWorld(plan, roots, generator);
|
||||||
|
const installation = await compiler.loadExecutionInstallation(plan, roots);
|
||||||
|
const kernel = compiler.compileKernelProgram(plan),
|
||||||
|
queries = compiler.compileQueryProgram(plan);
|
||||||
|
const graph = compiler.graphExecutionRepresentation(plan, installation, kernel, queries);
|
||||||
|
for (const [file, value] of [
|
||||||
|
["graph-execution.json", graph],
|
||||||
|
["execution-world.json", checked.world],
|
||||||
|
["installation.json", installation],
|
||||||
|
["kernel.json", kernel],
|
||||||
|
["queries.json", queries],
|
||||||
|
[
|
||||||
|
"execution-artifacts.json",
|
||||||
|
{
|
||||||
|
codec: "camino-checked-execution-v2",
|
||||||
|
generator,
|
||||||
|
roots,
|
||||||
|
manifests: checked.manifests,
|
||||||
|
execution: compiler.hexDigest(graph),
|
||||||
|
programs: { kernel: compiler.hexDigest(kernel), queries: compiler.hexDigest(queries) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
await writeFile(join(output, file), JSON.stringify(value) + "\n");
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// An independent package check witnesses its contract and bytes, without
|
||||||
|
// inventing a workspace or granting installation/activation authority.
|
||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { join } from "node:path";
|
||||||
|
const [compilerPath, schemaPath, checksPath, output] = process.argv.slice(2);
|
||||||
|
const compiler = await import(pathToFileURL(compilerPath));
|
||||||
|
const schema = JSON.parse(await readFile(schemaPath, "utf8"));
|
||||||
|
const checks = JSON.parse(await readFile(checksPath, "utf8"));
|
||||||
|
const generator = createHash("sha256")
|
||||||
|
.update(await readFile(compilerPath))
|
||||||
|
.digest("hex");
|
||||||
|
const evidence = [];
|
||||||
|
for (const check of checks) {
|
||||||
|
const pkg = schema.packages.find((pkg) => pkg.revisionId === check.packageRevisionId);
|
||||||
|
if (!pkg) throw Error("Unknown checked package");
|
||||||
|
const contract = compiler.packageBuildContract(schema, pkg.revisionId);
|
||||||
|
const sourceDigest = compiler.hexDigest({ revision: pkg.revisionId, source: pkg.source });
|
||||||
|
const manifest = compiler.parsePackageArtifacts(
|
||||||
|
JSON.parse(await readFile(join(check.artifactPath, "share/quixos/package-artifacts.json"), "utf8")),
|
||||||
|
);
|
||||||
|
compiler.checkPackageCoverage(
|
||||||
|
{ ...contract.definition.package, bindingDigest: contract.digest, sourceDigest },
|
||||||
|
manifest,
|
||||||
|
);
|
||||||
|
const receipt = JSON.parse(await readFile(join(check.artifactPath, "share/quixos/build-receipt.json"), "utf8"));
|
||||||
|
const expected = compiler.expectedPackageReceipt(contract, manifest, sourceDigest, generator);
|
||||||
|
if (compiler.hexDigest(receipt) !== compiler.hexDigest(expected.receipt))
|
||||||
|
throw Error("Invalid package build receipt");
|
||||||
|
const read = await compiler.artifactFileReader({ [pkg.revisionId]: check.artifactPath });
|
||||||
|
await compiler.checkArtifactFiles(manifest, read);
|
||||||
|
for (const [file, bytes] of expected.sources)
|
||||||
|
if (!Buffer.from(bytes).equals(await read(pkg.revisionId, file, bytes.length)))
|
||||||
|
throw Error("Modified generated binding " + file);
|
||||||
|
evidence.push({ ...check, contract: contract.digest, sourceDigest, receipt });
|
||||||
|
}
|
||||||
|
await writeFile(
|
||||||
|
join(output, "package-checks.json"),
|
||||||
|
JSON.stringify({ codec: "camino-package-checks-v1", generator, packages: evidence }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Candidate artifact evaluation. This is separate from the old server activation path;
|
||||||
|
# the new coordinator consumes its manifest/receipt, not the legacy server ABI.
|
||||||
|
{
|
||||||
|
source,
|
||||||
|
plan,
|
||||||
|
generator,
|
||||||
|
core,
|
||||||
|
sharedRuntime,
|
||||||
|
typescript,
|
||||||
|
packageRevisionId,
|
||||||
|
system ? builtins.currentSystem,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
packageSource = builtins.path {
|
||||||
|
path = /. + source;
|
||||||
|
name = "quixos-package-source";
|
||||||
|
filter =
|
||||||
|
path: _:
|
||||||
|
!(builtins.elem (baseNameOf path) [
|
||||||
|
".git"
|
||||||
|
".jj"
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
package = builtins.getFlake (
|
||||||
|
"path:" + builtins.unsafeDiscardStringContext (toString packageSource)
|
||||||
|
);
|
||||||
|
pkgs = import package.inputs.nixpkgs { inherit system; };
|
||||||
|
buildContext = import ./package-context.nix {
|
||||||
|
inherit pkgs packageRevisionId;
|
||||||
|
protocol = builtins.storePath generator;
|
||||||
|
plan = builtins.path {
|
||||||
|
path = /. + plan;
|
||||||
|
name = "candidate-execution-plan.json";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
checked =
|
||||||
|
package.quixosPackages.${system}.checkedArtifacts
|
||||||
|
or (throw "Package ${packageRevisionId} lacks checkedArtifacts for the portable execution world.");
|
||||||
|
in
|
||||||
|
checked (
|
||||||
|
buildContext
|
||||||
|
// {
|
||||||
|
inherit packageRevisionId;
|
||||||
|
generator = builtins.storePath generator;
|
||||||
|
core = builtins.storePath core;
|
||||||
|
sharedRuntime = builtins.storePath sharedRuntime;
|
||||||
|
typescript = builtins.storePath typescript;
|
||||||
|
}
|
||||||
|
)
|
||||||
+332
-34
@@ -6,10 +6,16 @@
|
|||||||
kind,
|
kind,
|
||||||
generator,
|
generator,
|
||||||
contractOnly ? false,
|
contractOnly ? false,
|
||||||
|
workspaceId ? null,
|
||||||
|
platformSourceJSON ? null,
|
||||||
system ? builtins.currentSystem,
|
system ? builtins.currentSystem,
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
pkgs = import (/. + "@nixpkgs@") { inherit system; };
|
pkgs = import (/. + "@nixpkgs@") {
|
||||||
|
inherit system;
|
||||||
|
config = { };
|
||||||
|
overlays = [ ];
|
||||||
|
};
|
||||||
protocol = builtins.storePath generator;
|
protocol = builtins.storePath generator;
|
||||||
sourceKey = source: "${source.kind}:${source.repository}@${source.commit}";
|
sourceKey = source: "${source.kind}:${source.repository}@${source.commit}";
|
||||||
fetch =
|
fetch =
|
||||||
@@ -20,35 +26,112 @@ let
|
|||||||
ref = "refs/tags/quixos-reachability/${source.commit}";
|
ref = "refs/tags/quixos-reachability/${source.commit}";
|
||||||
shallow = true;
|
shallow = true;
|
||||||
};
|
};
|
||||||
load =
|
# Memoize by exact source identity. A diamond-shaped dependency graph must
|
||||||
ancestors: source:
|
# not recursively evaluate every path through the same imported subtree.
|
||||||
if builtins.elem (sourceKey source) ancestors then
|
loadSource =
|
||||||
throw "Source dependency cycle at ${sourceKey source}"
|
source:
|
||||||
else if builtins.length ancestors >= 100 then
|
|
||||||
throw "Source dependency depth exceeds 100"
|
|
||||||
else
|
|
||||||
let
|
let
|
||||||
directory = fetch source;
|
directory = fetch source;
|
||||||
|
lockSource = import ./lock-source.nix { source = directory; };
|
||||||
lockFile = pkgs.runCommand "qx-source-lock.json" { } ''
|
lockFile = pkgs.runCommand "qx-source-lock.json" { } ''
|
||||||
${protocol}/bin/quixos-lock-check ${directory}/quixos.lock > "$out"
|
${protocol}/bin/quixos-lock-check ${lockSource}/quixos.lock > "$out"
|
||||||
'';
|
'';
|
||||||
lock = builtins.fromJSON (builtins.readFile lockFile);
|
lock = builtins.fromJSON (builtins.readFile lockFile);
|
||||||
children = map (
|
|
||||||
entry: load (ancestors ++ [ (sourceKey source) ]) (entry.source // { inherit (entry) kind; })
|
|
||||||
) lock.resources;
|
|
||||||
in
|
in
|
||||||
source // { inherit directory children; };
|
source // { inherit directory lock; };
|
||||||
root = load [ ] { inherit kind repository commit; };
|
sourceEntry = source: {
|
||||||
flatten = node: [ node ] ++ pkgs.lib.concatMap flatten node.children;
|
key = sourceKey source;
|
||||||
nodes = builtins.attrValues (
|
value = loadSource source;
|
||||||
builtins.listToAttrs (
|
};
|
||||||
map (node: {
|
sourceGraph = builtins.genericClosure {
|
||||||
name = sourceKey node;
|
startSet = [ (sourceEntry { inherit kind repository commit; }) ];
|
||||||
value = node;
|
operator =
|
||||||
}) (flatten root)
|
entry:
|
||||||
)
|
map (
|
||||||
|
resource: sourceEntry (resource.source // { inherit (resource) kind; })
|
||||||
|
) entry.value.lock.resources;
|
||||||
|
};
|
||||||
|
nodeTable = builtins.listToAttrs (
|
||||||
|
map (entry: {
|
||||||
|
name = entry.key;
|
||||||
|
value = entry.value // {
|
||||||
|
children = map (
|
||||||
|
resource:
|
||||||
|
nodeTable.${sourceKey (resource.source // { inherit (resource) kind; })}
|
||||||
|
// {
|
||||||
|
inherit (resource) binding;
|
||||||
|
}
|
||||||
|
) entry.value.lock.resources;
|
||||||
|
};
|
||||||
|
}) sourceGraph
|
||||||
);
|
);
|
||||||
snapshots = pkgs.writeText "qx-nix-source-graph.json" (
|
root = nodeTable.${sourceKey { inherit kind repository commit; }};
|
||||||
|
configuration =
|
||||||
|
if builtins.pathExists (root.directory + "/quixos.check.json") then
|
||||||
|
builtins.fromJSON (builtins.readFile (root.directory + "/quixos.check.json"))
|
||||||
|
else
|
||||||
|
{ };
|
||||||
|
execution = (configuration.backend or "typescript") == "execution";
|
||||||
|
platformSource = if platformSourceJSON == null then null else builtins.fromJSON platformSourceJSON;
|
||||||
|
runtime =
|
||||||
|
if platformSource == null then
|
||||||
|
builtins.getFlake (
|
||||||
|
"git+${root.lock.quixos.repository}?rev=${root.lock.quixos.commit}&ref=refs/tags/quixos-reachability/${root.lock.quixos.commit}"
|
||||||
|
)
|
||||||
|
else
|
||||||
|
assert platformSource.repository == root.lock.quixos.repository;
|
||||||
|
assert platformSource.commit == root.lock.quixos.commit;
|
||||||
|
# getFlake accepts a context-free flake reference. storePath first checks
|
||||||
|
# presence; the resulting flake retains its immutable source as an input.
|
||||||
|
# Dropping string context here does not permit an authored mutable path.
|
||||||
|
builtins.getFlake (
|
||||||
|
builtins.unsafeDiscardStringContext "path:${builtins.storePath platformSource.directory}"
|
||||||
|
);
|
||||||
|
runtimePackages = runtime.packages.${system};
|
||||||
|
resourceClosure =
|
||||||
|
includeBuild: node:
|
||||||
|
map (entry: entry.value) (
|
||||||
|
builtins.genericClosure {
|
||||||
|
startSet = pkgs.lib.optional (includeBuild || node.kind != "build") {
|
||||||
|
key = sourceKey node;
|
||||||
|
value = node;
|
||||||
|
};
|
||||||
|
operator =
|
||||||
|
entry:
|
||||||
|
map (child: {
|
||||||
|
key = sourceKey child;
|
||||||
|
value = child;
|
||||||
|
}) (builtins.filter (child: includeBuild || child.kind != "build") entry.value.children);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
capabilityFlatten = resourceClosure false;
|
||||||
|
# Frontends are an explicit build choice. The capability assembler itself
|
||||||
|
# neither discovers React contracts nor loads executable package code.
|
||||||
|
queryFrontends =
|
||||||
|
node:
|
||||||
|
pkgs.lib.unique (
|
||||||
|
pkgs.lib.concatMap (
|
||||||
|
node:
|
||||||
|
let
|
||||||
|
config = node.directory + "/quixos.check.json";
|
||||||
|
in
|
||||||
|
if builtins.pathExists config then
|
||||||
|
(builtins.fromJSON (builtins.readFile config)).queryFrontends or [ ]
|
||||||
|
else
|
||||||
|
[ ]
|
||||||
|
) (capabilityFlatten node)
|
||||||
|
);
|
||||||
|
queryGeneratorFlags =
|
||||||
|
node:
|
||||||
|
if queryFrontends node == [ ] then
|
||||||
|
""
|
||||||
|
else if queryFrontends node == [ "react" ] then
|
||||||
|
"--query-generator ${protocol}/libexec/quixos-protocol/react-query-generator.mjs"
|
||||||
|
else
|
||||||
|
throw "Unsupported query frontend selection: ${builtins.toJSON (queryFrontends node)}";
|
||||||
|
snapshots =
|
||||||
|
node:
|
||||||
|
pkgs.writeText "qx-nix-source-graph.json" (
|
||||||
builtins.toJSON {
|
builtins.toJSON {
|
||||||
resources = map (node: {
|
resources = map (node: {
|
||||||
inherit (node)
|
inherit (node)
|
||||||
@@ -57,44 +140,199 @@ let
|
|||||||
commit
|
commit
|
||||||
directory
|
directory
|
||||||
;
|
;
|
||||||
}) (builtins.filter (node: node.kind != "workspace") nodes);
|
}) (builtins.filter (entry: entry.kind != "workspace") (resourceClosure true node));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
compile =
|
# Each resource is checked once per exact source/closure/frontend/toolchain.
|
||||||
|
# Composition imports these compiler outputs; authoring receipts are never
|
||||||
|
# build inputs. Missing entries in ordinary CLI use take the full check path.
|
||||||
|
compiledNodes = builtins.mapAttrs (_: node: compileNode node) nodeTable;
|
||||||
|
compile = node: compiledNodes.${sourceKey node};
|
||||||
|
checkedResources =
|
||||||
node:
|
node:
|
||||||
pkgs.runCommand "qx-${node.kind}-contract" { } ''
|
pkgs.writeText "qx-checked-resources.json" (
|
||||||
|
builtins.toJSON (
|
||||||
|
map
|
||||||
|
(dependency: {
|
||||||
|
inherit (dependency) kind repository commit;
|
||||||
|
artifact = compile dependency;
|
||||||
|
})
|
||||||
|
(
|
||||||
|
builtins.filter (
|
||||||
|
dependency:
|
||||||
|
dependency.kind != "workspace"
|
||||||
|
&& dependency.kind != "build"
|
||||||
|
&& sourceKey dependency != sourceKey node
|
||||||
|
) (resourceClosure true node)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
compileNode =
|
||||||
|
node:
|
||||||
|
pkgs.runCommand "qx-${node.kind}-contract" { QUIXOS_TRACE_PROFILE = "1"; } ''
|
||||||
mkdir -p "$out"
|
mkdir -p "$out"
|
||||||
${
|
${
|
||||||
if node.kind == "workspace" then
|
if node.kind == "workspace" then
|
||||||
''
|
''
|
||||||
${protocol}/bin/quixos-workspace-compile --root ${node.directory} \
|
${protocol}/bin/quixos-workspace-compile --root ${node.directory} \
|
||||||
|
${queryGeneratorFlags root} \
|
||||||
|
--checked-resources ${checkedResources node} \
|
||||||
--source-root-commit ${pkgs.lib.escapeShellArg node.commit} \
|
--source-root-commit ${pkgs.lib.escapeShellArg node.commit} \
|
||||||
--checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} \
|
${
|
||||||
--graph-out "$out/graph.json" --schemas-out "$out/package-schemas.json" > "$out/candidate.json"
|
pkgs.lib.optionalString (
|
||||||
|
workspaceId != null
|
||||||
|
) "--workspace-id ${pkgs.lib.escapeShellArg workspaceId}"
|
||||||
|
} \
|
||||||
|
--checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots node} \
|
||||||
|
--graph-out "$out/graph.json" --schemas-out "$out/package-schemas.json" \
|
||||||
|
${
|
||||||
|
pkgs.lib.optionalString (execution && kind == "workspace")
|
||||||
|
''--execution-plan-out "$out/execution-plan.json" --build-contexts-out "$out/build-contexts.json"''
|
||||||
|
} > "$out/candidate.json"
|
||||||
''
|
''
|
||||||
else
|
else
|
||||||
''
|
''
|
||||||
${protocol}/bin/quixos-resource-compile --root ${node.directory} \
|
${protocol}/bin/quixos-resource-compile --root ${node.directory} \
|
||||||
|
${queryGeneratorFlags root} \
|
||||||
|
--checked-resources ${checkedResources node} \
|
||||||
--kind ${node.kind} --repository ${pkgs.lib.escapeShellArg node.repository} \
|
--kind ${node.kind} --repository ${pkgs.lib.escapeShellArg node.repository} \
|
||||||
--commit ${pkgs.lib.escapeShellArg node.commit} \
|
--commit ${pkgs.lib.escapeShellArg node.commit} \
|
||||||
--checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} --snapshot-only true \
|
--checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots node} --snapshot-only true \
|
||||||
--graph-out "$out/graph.json" --schema-out "$out/bindings.json" > "$out/candidate.json"
|
--graph-out "$out/graph.json" --schema-out "$out/bindings.json" > "$out/candidate.json"
|
||||||
''
|
''
|
||||||
}
|
}
|
||||||
'';
|
'';
|
||||||
contract = compile root;
|
contract = compile root;
|
||||||
|
# Parse shared workspace outputs once. Repeating fromJSON in every package
|
||||||
|
# makes composition quadratic in package count and in the serialized world.
|
||||||
|
workspacePackages = builtins.listToAttrs (
|
||||||
|
map
|
||||||
|
(pkg: {
|
||||||
|
name = sourceKey (pkg.source // { kind = "package"; });
|
||||||
|
value = pkg;
|
||||||
|
})
|
||||||
|
(builtins.fromJSON (builtins.readFile "${contract}/execution-plan.json"))
|
||||||
|
.definition.bindingSchema.packages
|
||||||
|
);
|
||||||
|
workspaceContexts = builtins.fromJSON (builtins.readFile "${contract}/build-contexts.json");
|
||||||
|
buildDependencies =
|
||||||
|
node:
|
||||||
|
builtins.listToAttrs (
|
||||||
|
map (dependency: {
|
||||||
|
name = dependency.binding;
|
||||||
|
value = {
|
||||||
|
src = dependency.directory;
|
||||||
|
source = { inherit (dependency) repository commit; };
|
||||||
|
flake = builtins.getFlake "git+${dependency.repository}?rev=${dependency.commit}&ref=refs/tags/quixos-reachability/${dependency.commit}";
|
||||||
|
};
|
||||||
|
}) (builtins.filter (dependency: dependency.kind == "build") node.children)
|
||||||
|
);
|
||||||
|
reactContracts =
|
||||||
|
node: moduleInterfaces:
|
||||||
|
let
|
||||||
|
interfaces = builtins.attrValues (
|
||||||
|
builtins.listToAttrs (
|
||||||
|
map (dependency: {
|
||||||
|
name = sourceKey dependency;
|
||||||
|
value = dependency;
|
||||||
|
}) (builtins.filter (dependency: dependency.kind == "interface") (capabilityFlatten node))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
in
|
||||||
|
pkgs.lib.concatMap (
|
||||||
|
dependency:
|
||||||
|
if !builtins.pathExists (dependency.directory + "/flake.nix") then
|
||||||
|
[ ]
|
||||||
|
else
|
||||||
|
let
|
||||||
|
source = builtins.getFlake "git+${dependency.repository}?rev=${dependency.commit}&ref=refs/tags/quixos-reachability/${dependency.commit}";
|
||||||
|
build = source.quixosInterfaces.${system}.reactTypes or null;
|
||||||
|
in
|
||||||
|
if build == null then
|
||||||
|
[ ]
|
||||||
|
else if
|
||||||
|
!(builtins.any (
|
||||||
|
entry:
|
||||||
|
let
|
||||||
|
resource = builtins.fromJSON (builtins.readFile "${compile dependency}/candidate.json");
|
||||||
|
in
|
||||||
|
entry.revisionId == resource.revision.revisionId
|
||||||
|
|| (entry.definitionId or null) == resource.revision.revisionId
|
||||||
|
) moduleInterfaces)
|
||||||
|
then
|
||||||
|
[ ]
|
||||||
|
else
|
||||||
|
[
|
||||||
|
(build {
|
||||||
|
interface = "${compile dependency}/candidate.json";
|
||||||
|
generator = protocol;
|
||||||
|
typescript = runtimePackages.camino-replica-core.dependencies;
|
||||||
|
reactSdk = runtimePackages.camino-react;
|
||||||
|
dependencies = buildDependencies dependency;
|
||||||
|
})
|
||||||
|
]
|
||||||
|
) interfaces;
|
||||||
checkPackage =
|
checkPackage =
|
||||||
node:
|
node:
|
||||||
let
|
let
|
||||||
compiled = compile node;
|
compiled = compile node;
|
||||||
candidate = builtins.fromJSON (builtins.readFile "${compiled}/candidate.json");
|
candidate =
|
||||||
|
if execution && kind == "workspace" then
|
||||||
|
{
|
||||||
|
revision =
|
||||||
|
workspacePackages.${sourceKey node}
|
||||||
|
or (throw "Source package missing from workspace compilation: ${node.repository}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
builtins.fromJSON (builtins.readFile "${compiled}/candidate.json");
|
||||||
|
buildContext = import ./package-context.nix {
|
||||||
|
inherit pkgs protocol;
|
||||||
|
contexts = if kind == "workspace" then "${contract}/build-contexts.json" else null;
|
||||||
|
contextData =
|
||||||
|
if execution && kind == "workspace" then
|
||||||
|
workspaceContexts.${candidate.revision.revisionId}
|
||||||
|
else
|
||||||
|
null;
|
||||||
|
plan = "${compiled}/bindings.json";
|
||||||
|
packageRevisionId = candidate.revision.revisionId;
|
||||||
|
};
|
||||||
|
buildContract = builtins.fromJSON (builtins.readFile buildContext.buildContract);
|
||||||
|
componentEntries = if execution then buildContract.definition.package.components or [ ] else [ ];
|
||||||
package = builtins.getFlake (
|
package = builtins.getFlake (
|
||||||
"git+${node.repository}?rev=${node.commit}&ref=refs/tags/quixos-reachability/${node.commit}"
|
"git+${node.repository}?rev=${node.commit}&ref=refs/tags/quixos-reachability/${node.commit}"
|
||||||
);
|
);
|
||||||
checked =
|
checked =
|
||||||
|
if execution then
|
||||||
|
package.quixosPackages.${system}.checkedArtifacts
|
||||||
|
or (throw "Package ${node.repository} lacks checkedArtifacts; execution workspaces cannot import legacy servers.")
|
||||||
|
else
|
||||||
package.quixosPackages.${system}.checkedServer
|
package.quixosPackages.${system}.checkedServer
|
||||||
or (throw "Package ${node.repository} lacks checkedServer; use the supported package scaffold.");
|
or (throw "Package ${node.repository} lacks checkedServer; use the supported package scaffold.");
|
||||||
artifact = checked {
|
artifact = checked (
|
||||||
|
if execution then
|
||||||
|
buildContext
|
||||||
|
// {
|
||||||
|
generator = protocol;
|
||||||
|
core = runtimePackages.camino-replica-core;
|
||||||
|
sharedRuntime = runtimePackages.camino-shared-runtime;
|
||||||
|
typescript = runtimePackages.camino-replica-core.dependencies;
|
||||||
|
packageRevisionId = candidate.revision.revisionId;
|
||||||
|
dependencies = buildDependencies node;
|
||||||
|
buildEnvironment = {
|
||||||
|
inherit pkgs system;
|
||||||
|
nixpkgsSource = toString pkgs.path;
|
||||||
|
narHash = "@nixpkgsNarHash@";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// pkgs.lib.optionalAttrs (componentEntries != [ ]) {
|
||||||
|
react = {
|
||||||
|
sdk = runtimePackages.camino-react;
|
||||||
|
runtime = runtimePackages.camino-react-runtime;
|
||||||
|
contracts = reactContracts node buildContract.definition.moduleInterfaces;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
schema =
|
schema =
|
||||||
if kind == "workspace" then
|
if kind == "workspace" then
|
||||||
pkgs.writeText "package-specialization-schema.json" (
|
pkgs.writeText "package-specialization-schema.json" (
|
||||||
@@ -106,7 +344,8 @@ let
|
|||||||
"${compiled}/bindings.json";
|
"${compiled}/bindings.json";
|
||||||
generator = protocol;
|
generator = protocol;
|
||||||
packageRevisionId = candidate.revision.revisionId;
|
packageRevisionId = candidate.revision.revisionId;
|
||||||
};
|
}
|
||||||
|
);
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
packageRevisionId = candidate.revision.revisionId;
|
packageRevisionId = candidate.revision.revisionId;
|
||||||
@@ -116,7 +355,20 @@ let
|
|||||||
if contractOnly then
|
if contractOnly then
|
||||||
[ ]
|
[ ]
|
||||||
else
|
else
|
||||||
map checkPackage (builtins.filter (node: node.kind == "package") nodes);
|
map checkPackage (
|
||||||
|
builtins.filter
|
||||||
|
(node: node.kind == "package" && (kind == "workspace" || sourceKey node == sourceKey root))
|
||||||
|
(
|
||||||
|
builtins.attrValues (
|
||||||
|
builtins.listToAttrs (
|
||||||
|
map (node: {
|
||||||
|
name = sourceKey node;
|
||||||
|
value = node;
|
||||||
|
}) (capabilityFlatten root)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
manifest = pkgs.writeText "qx-candidate-checks.json" (builtins.toJSON checks);
|
manifest = pkgs.writeText "qx-candidate-checks.json" (builtins.toJSON checks);
|
||||||
bindingOptions = pkgs.writeText "qx-typescript-options.json" (
|
bindingOptions = pkgs.writeText "qx-typescript-options.json" (
|
||||||
builtins.toJSON (
|
builtins.toJSON (
|
||||||
@@ -124,15 +376,61 @@ let
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
in
|
in
|
||||||
|
assert
|
||||||
|
workspaceId == null
|
||||||
|
|| (
|
||||||
|
kind == "workspace"
|
||||||
|
&& builtins.isString workspaceId
|
||||||
|
&& builtins.match "[a-zA-Z0-9][a-zA-Z0-9:._-]{0,255}" workspaceId != null
|
||||||
|
);
|
||||||
pkgs.runCommand (if contractOnly then "qx-contract" else "qx-checked-candidate") { } ''
|
pkgs.runCommand (if contractOnly then "qx-contract" else "qx-checked-candidate") { } ''
|
||||||
mkdir -p "$out"
|
mkdir -p "$out"
|
||||||
|
cp ${
|
||||||
|
builtins.toFile "qx-candidate-source.json" (builtins.toJSON { inherit repository commit kind; })
|
||||||
|
} "$out/source.json"
|
||||||
cp ${contract}/candidate.json "$out/candidate.json"
|
cp ${contract}/candidate.json "$out/candidate.json"
|
||||||
cp ${contract}/graph.json "$out/graph.json"
|
cp ${contract}/graph.json "$out/graph.json"
|
||||||
cp ${manifest} "$out/checks.json"
|
cp ${manifest} "$out/checks.json"
|
||||||
|
${pkgs.lib.optionalString (execution && kind == "workspace") ''
|
||||||
|
${
|
||||||
|
if builtins.pathExists "${root.directory}/quixos.entry.json" then
|
||||||
|
''
|
||||||
|
cp ${root.directory}/quixos.entry.json "$out/entry.json"
|
||||||
|
''
|
||||||
|
else
|
||||||
|
''
|
||||||
|
echo null > "$out/entry.json"
|
||||||
|
''
|
||||||
|
}
|
||||||
|
cp ${contract}/execution-plan.json "$out/execution-plan.json"
|
||||||
|
${pkgs.lib.optionalString (!contractOnly) ''
|
||||||
|
${pkgs.nodejs_24}/bin/node ${./check-execution.mjs} \
|
||||||
|
${protocol}/libexec/quixos-protocol/execution-world.mjs \
|
||||||
|
"$out/execution-plan.json" "$out/checks.json" "$out"
|
||||||
|
# The optional development host is acquired only when explicitly run.
|
||||||
|
# Embedding its executable here made every source check build Web Studio
|
||||||
|
# and a full host even when only checked Package outputs were consumed.
|
||||||
|
cp ${
|
||||||
|
pkgs.writeText "qx-isolated-runtime.json" (
|
||||||
|
builtins.toJSON {
|
||||||
|
codec = "quixos-isolated-runtime/1";
|
||||||
|
source = {
|
||||||
|
inherit (root.lock.quixos) repository commit;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} "$out/isolated-runtime.json"
|
||||||
|
''}
|
||||||
|
''}
|
||||||
${pkgs.lib.optionalString (
|
${pkgs.lib.optionalString (
|
||||||
kind != "workspace"
|
kind != "workspace"
|
||||||
) ''cp ${contract}/bindings.json "$out/bindings.json"''}
|
) ''cp ${contract}/bindings.json "$out/bindings.json"''}
|
||||||
${pkgs.lib.optionalString (kind == "package") ''
|
${pkgs.lib.optionalString (execution && kind == "package" && !contractOnly) ''
|
||||||
|
${pkgs.nodejs_24}/bin/node ${./check-package.mjs} \
|
||||||
|
${protocol}/libexec/quixos-protocol/execution-world.mjs ${contract}/bindings.json \
|
||||||
|
"$out/checks.json" "$out"
|
||||||
|
''}
|
||||||
|
${pkgs.lib.optionalString (kind == "package" && !execution) ''
|
||||||
${protocol}/bin/quixos-codegen-ts ${contract}/bindings.json \
|
${protocol}/bin/quixos-codegen-ts ${contract}/bindings.json \
|
||||||
${pkgs.lib.escapeShellArg (builtins.fromJSON (builtins.readFile "${contract}/candidate.json")).revision.revisionId} \
|
${pkgs.lib.escapeShellArg (builtins.fromJSON (builtins.readFile "${contract}/candidate.json")).revision.revisionId} \
|
||||||
"$out/bindings.ts" ${bindingOptions}
|
"$out/bindings.ts" ${bindingOptions}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Lock parsing only reads this one file when no import token can occur. A
|
||||||
|
# conservative substring probe also sends comments/URLs containing "import"
|
||||||
|
# through the complete-source path. Fragment names may have any extension.
|
||||||
|
{ source }:
|
||||||
|
let
|
||||||
|
regular = ((builtins.readDir source)."quixos.lock" or null) == "regular";
|
||||||
|
text = if regular then builtins.readFile (source + "/quixos.lock") else "import";
|
||||||
|
in
|
||||||
|
if builtins.replaceStrings [ "import" ] [ "" ] text != text then
|
||||||
|
source
|
||||||
|
else
|
||||||
|
builtins.path {
|
||||||
|
path = source;
|
||||||
|
name = "qx-lock-source";
|
||||||
|
filter = filename: _: filename == toString source || filename == "${source}/quixos.lock";
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{ pkgs, protocol }:
|
||||||
|
let
|
||||||
|
base = ''quixos-lock version 1 { quixos source { repository "https://example.test/quixos.git"; commit "1111111111111111111111111111111111111111"; } }'';
|
||||||
|
fixture =
|
||||||
|
extra: lock:
|
||||||
|
pkgs.runCommand "qx-lock-source-fixture" { } ''
|
||||||
|
mkdir -p "$out/nested"
|
||||||
|
printf '%s' ${pkgs.lib.escapeShellArg lock} > "$out/quixos.lock"
|
||||||
|
printf '%s' ${pkgs.lib.escapeShellArg extra} > "$out/implementation.ts"
|
||||||
|
printf '%s' 'quixos-lock fragment version 1 {}' > "$out/nested/dependencies.txt"
|
||||||
|
'';
|
||||||
|
first = fixture "first implementation" base;
|
||||||
|
edited = fixture "edited implementation" base;
|
||||||
|
changed = fixture "first implementation" (
|
||||||
|
builtins.replaceStrings
|
||||||
|
[ "1111111111111111111111111111111111111111" ]
|
||||||
|
[ "2222222222222222222222222222222222222222" ]
|
||||||
|
base
|
||||||
|
);
|
||||||
|
fragments = fixture "first implementation" (
|
||||||
|
builtins.replaceStrings [ "quixos source" ] [ ''import "nested/dependencies.txt"; quixos source'' ]
|
||||||
|
base
|
||||||
|
);
|
||||||
|
conservative = fixture "first implementation" (base + "\n// import in a comment\n");
|
||||||
|
slice = source: import ./lock-source.nix { inherit source; };
|
||||||
|
parse =
|
||||||
|
source:
|
||||||
|
pkgs.runCommand "qx-source-lock.json" { } ''
|
||||||
|
${protocol}/bin/quixos-lock-check ${slice source}/quixos.lock > "$out"
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
assert slice first == slice edited;
|
||||||
|
assert (parse first).drvPath == (parse edited).drvPath;
|
||||||
|
assert slice first != slice changed;
|
||||||
|
pkgs.runCommand "qx-lock-source-check" { } ''
|
||||||
|
${protocol}/bin/quixos-lock-check ${slice first}/quixos.lock > first.json
|
||||||
|
${protocol}/bin/quixos-lock-check ${slice edited}/quixos.lock > edited.json
|
||||||
|
cmp first.json edited.json
|
||||||
|
${protocol}/bin/quixos-lock-check ${slice conservative}/quixos.lock > comment.json
|
||||||
|
cmp first.json comment.json
|
||||||
|
${protocol}/bin/quixos-lock-check ${slice fragments}/quixos.lock > "$out"
|
||||||
|
''
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
const compiler = await import(pathToFileURL(process.argv[2]));
|
||||||
|
const input = JSON.parse(await fs.readFile(process.argv[3], "utf8"));
|
||||||
|
const revision = process.argv[4];
|
||||||
|
const schema = input.definition?.bindingSchema ?? input;
|
||||||
|
if (input.definition && compiler.hexDigest(input.definition) !== input.digest) throw Error("Invalid execution plan");
|
||||||
|
const pkg = schema.packages.find((entry) => entry.revisionId === revision);
|
||||||
|
if (!pkg) throw Error("Unknown build package " + revision);
|
||||||
|
process.stdout.write(
|
||||||
|
JSON.stringify({
|
||||||
|
contract: compiler.packageBuildContract(schema, revision),
|
||||||
|
sourceDigest: compiler.hexDigest({ revision, source: pkg.source }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Workspace composition may reevaluate this projection after any graph edit.
|
||||||
|
# Only the canonical, source-free value becomes a package compilation input.
|
||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
protocol,
|
||||||
|
plan ? null,
|
||||||
|
contexts ? null,
|
||||||
|
contextData ? null,
|
||||||
|
packageRevisionId,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
projected = pkgs.runCommand "camino-package-context.json" { } ''
|
||||||
|
${pkgs.nodejs_24}/bin/node ${./package-context.mjs} \
|
||||||
|
${protocol}/libexec/quixos-protocol/execution-world.mjs ${plan} \
|
||||||
|
${pkgs.lib.escapeShellArg packageRevisionId} > "$out"
|
||||||
|
'';
|
||||||
|
context =
|
||||||
|
if contextData != null then
|
||||||
|
contextData
|
||||||
|
else if contexts == null then
|
||||||
|
builtins.fromJSON (builtins.readFile projected)
|
||||||
|
else
|
||||||
|
(builtins.fromJSON (builtins.readFile contexts)).${packageRevisionId};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
# This is typed semantic data (nominal IDs, types and generated source), never
|
||||||
|
# Nix artifact paths. Remove the projection derivation's string context, not
|
||||||
|
# any code/toolchain dependency. Those are separate explicit helper arguments.
|
||||||
|
buildContract = builtins.toFile "camino-package-contract.json" (
|
||||||
|
builtins.unsafeDiscardStringContext (builtins.toJSON context.contract)
|
||||||
|
);
|
||||||
|
sourceDigest = builtins.unsafeDiscardStringContext context.sourceDigest;
|
||||||
|
}
|
||||||
+5
-2
@@ -6,6 +6,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"quixos-capability-compile": "dist/src/capability-language/cli.js",
|
"quixos-capability-compile": "dist/src/capability-language/cli.js",
|
||||||
|
"quixos-codegen-client": "dist/src/bindings/portable-client-cli.js",
|
||||||
"quixos-codegen-ts": "dist/src/bindings/cli.js",
|
"quixos-codegen-ts": "dist/src/bindings/cli.js",
|
||||||
"quixos-descriptor-check": "dist/src/descriptor-check.js",
|
"quixos-descriptor-check": "dist/src/descriptor-check.js",
|
||||||
"quixos-lock-check": "dist/src/resource-lock/cli.js",
|
"quixos-lock-check": "dist/src/resource-lock/cli.js",
|
||||||
@@ -18,7 +19,8 @@
|
|||||||
"./capability-model": "./dist/src/capability-model/index.js",
|
"./capability-model": "./dist/src/capability-model/index.js",
|
||||||
"./capability-language": "./dist/src/capability-language/index.js",
|
"./capability-language": "./dist/src/capability-language/index.js",
|
||||||
"./resource-lock": "./dist/src/resource-lock/index.js",
|
"./resource-lock": "./dist/src/resource-lock/index.js",
|
||||||
"./gen/*": "./dist/src/gen/*"
|
"./gen/*": "./dist/src/gen/*",
|
||||||
|
"./execution-world": "./dist/src/execution-world/index.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "yarn generate && tsc -p tsconfig.build.json",
|
"build": "yarn generate && tsc -p tsconfig.build.json",
|
||||||
@@ -32,7 +34,8 @@
|
|||||||
"@babel/parser": "^7.28.0",
|
"@babel/parser": "^7.28.0",
|
||||||
"@bufbuild/protobuf": "^2.12.1",
|
"@bufbuild/protobuf": "^2.12.1",
|
||||||
"@bufbuild/protoc-gen-es": "^2.12.1",
|
"@bufbuild/protoc-gen-es": "^2.12.1",
|
||||||
"antlr4ng": "^3.0.16"
|
"antlr4ng": "^3.0.16",
|
||||||
|
"graphql": "16.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24",
|
"@types/node": "^24",
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ syntax = "proto3";
|
|||||||
package camino;
|
package camino;
|
||||||
|
|
||||||
import "camino/schema.proto";
|
import "camino/schema.proto";
|
||||||
|
import "quixos/refs.proto";
|
||||||
|
|
||||||
service CaminoService {
|
service CaminoService {
|
||||||
|
rpc ExecuteQuery(QueryRequest) returns (QueryResponse);
|
||||||
|
rpc QueryChanges(QueryChangesRequest) returns (QueryChangesResponse);
|
||||||
rpc InstallPersistencePlan(InstallPersistencePlanRequest) returns (InstallPersistencePlanResponse);
|
rpc InstallPersistencePlan(InstallPersistencePlanRequest) returns (InstallPersistencePlanResponse);
|
||||||
rpc GetPersistencePlan(GetPersistencePlanRequest) returns (GetPersistencePlanResponse);
|
rpc GetPersistencePlan(GetPersistencePlanRequest) returns (GetPersistencePlanResponse);
|
||||||
rpc CreateObject(CreateObjectRequest) returns (CreateObjectResponse);
|
rpc CreateObject(CreateObjectRequest) returns (CreateObjectResponse);
|
||||||
@@ -67,6 +70,27 @@ message Value {
|
|||||||
CrdtValue crdt_value = 10;
|
CrdtValue crdt_value = 10;
|
||||||
}
|
}
|
||||||
ValueSource source = 11;
|
ValueSource source = 11;
|
||||||
|
// Trusted query coordinator annotation. Travels with values through residual
|
||||||
|
// row selection, then is removed in favor of final-path hydration metadata.
|
||||||
|
QueryFieldOrigin query_origin = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
message QueryFieldOrigin {
|
||||||
|
string selection_id = 1;
|
||||||
|
string atom_id = 2;
|
||||||
|
string interface_revision_id = 3;
|
||||||
|
string member_id = 4;
|
||||||
|
StateValueSource source = 5;
|
||||||
|
}
|
||||||
|
message QueryLiveField {
|
||||||
|
repeated QueryPathPart path = 1;
|
||||||
|
string selection_id = 2;
|
||||||
|
string object_id = 3;
|
||||||
|
quixos.CapabilityRef capability = 4;
|
||||||
|
string watch_operation_id = 5;
|
||||||
|
string setter_operation_id = 6;
|
||||||
|
quixos.FieldEditing editing = 7;
|
||||||
|
StateValueSource source = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message InstallPersistencePlanRequest {
|
message InstallPersistencePlanRequest {
|
||||||
@@ -214,3 +238,128 @@ message CaminoOp {
|
|||||||
string created_at = 6;
|
string created_at = 6;
|
||||||
string client_mutation_id = 7;
|
string client_mutation_id = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message QueryRequest {
|
||||||
|
string query_id = 1;
|
||||||
|
oneof root {
|
||||||
|
string object_id = 2;
|
||||||
|
quixos.WorkspaceQueryScope workspace_scope = 5;
|
||||||
|
}
|
||||||
|
map<string, Value> variables = 3;
|
||||||
|
// Typed callers require this exact checked definition. Administrative/raw
|
||||||
|
// callers may omit it to explicitly select the currently installed definition.
|
||||||
|
string expected_definition_digest = 4;
|
||||||
|
}
|
||||||
|
message QueryPathPart {
|
||||||
|
oneof part {
|
||||||
|
string field = 1;
|
||||||
|
uint32 index = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message QueryPendingField {
|
||||||
|
repeated QueryPathPart path = 1;
|
||||||
|
string object_id = 2;
|
||||||
|
string interface_revision_id = 3;
|
||||||
|
string member_id = 4;
|
||||||
|
string operation_id = 5;
|
||||||
|
string value_type_json = 6;
|
||||||
|
// Internal residual facts are separate from the authored result projection.
|
||||||
|
optional uint32 residual_window = 7;
|
||||||
|
uint32 residual_row = 8;
|
||||||
|
string residual_field = 9;
|
||||||
|
optional uint32 relational_capture = 10;
|
||||||
|
uint32 captured_object = 11;
|
||||||
|
}
|
||||||
|
message QueryStats {
|
||||||
|
uint32 sql_count = 1;
|
||||||
|
double sql_ms = 2;
|
||||||
|
uint32 rpc_count = 3;
|
||||||
|
double rpc_ms = 4;
|
||||||
|
uint32 result_bytes = 5;
|
||||||
|
double preparation_ms = 6;
|
||||||
|
double total_ms = 7;
|
||||||
|
uint32 relational_stages = 8;
|
||||||
|
uint32 captured_candidates = 9;
|
||||||
|
double relational_ms = 10;
|
||||||
|
double residual_ms = 11;
|
||||||
|
}
|
||||||
|
message QueryResponse {
|
||||||
|
Value value = 1;
|
||||||
|
string data_version = 2;
|
||||||
|
string binding_digest = 3;
|
||||||
|
repeated QueryPendingField pending = 4;
|
||||||
|
QueryStats stats = 5;
|
||||||
|
// Redeemable only through the host's private control socket, not an RPC grant.
|
||||||
|
string preparation_token = 6;
|
||||||
|
repeated QueryFieldFailure errors = 7;
|
||||||
|
repeated QueryResidualWindow residual_windows = 8;
|
||||||
|
// Native reads share one database snapshot; package enrichment does not.
|
||||||
|
string consistency = 9; // native-snapshot | mixed
|
||||||
|
repeated QueryRelationalCapture relational_captures = 10;
|
||||||
|
repeated QueryLiveField live_fields = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private coordinator input, removed before publishing a result. Memberships
|
||||||
|
// and native facts share one snapshot; package reads are sampled afterwards.
|
||||||
|
message QueryCapturedMember {
|
||||||
|
string object_id = 1;
|
||||||
|
string entry_id = 2;
|
||||||
|
Value map_key = 3;
|
||||||
|
}
|
||||||
|
message QueryCapturedMembers {
|
||||||
|
repeated QueryCapturedMember entries = 1;
|
||||||
|
}
|
||||||
|
message QueryCapturedObject {
|
||||||
|
string object_id = 1;
|
||||||
|
map<string, Value> fields = 2;
|
||||||
|
map<string, string> field_types = 3;
|
||||||
|
map<string, QueryCapturedMembers> relationships = 4;
|
||||||
|
}
|
||||||
|
message QueryRelationalCapture {
|
||||||
|
repeated QueryPathPart path = 1;
|
||||||
|
QueryRelationalPlan plan = 2;
|
||||||
|
string root_object_id = 3;
|
||||||
|
repeated QueryCapturedObject objects = 4;
|
||||||
|
string variables_json = 5;
|
||||||
|
uint32 row_limit = 6;
|
||||||
|
uint32 candidate_limit = 7;
|
||||||
|
optional uint32 residual_window = 8;
|
||||||
|
repeated string result_path = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message QueryResidualRow {
|
||||||
|
string entry_id = 1;
|
||||||
|
map<string, Value> fields = 2;
|
||||||
|
}
|
||||||
|
message QueryResidualOrder {
|
||||||
|
string field = 1;
|
||||||
|
bool descending = 2;
|
||||||
|
}
|
||||||
|
message QueryResidualWindow {
|
||||||
|
repeated QueryPathPart path = 1;
|
||||||
|
repeated QueryResidualRow rows = 2;
|
||||||
|
string predicate_json = 3;
|
||||||
|
repeated QueryResidualOrder order = 4;
|
||||||
|
uint32 limit = 5;
|
||||||
|
bool bounded_all = 6;
|
||||||
|
repeated QuerySelection selection = 7;
|
||||||
|
map<string, string> field_types = 8;
|
||||||
|
bool relational = 9;
|
||||||
|
repeated string matched_entries = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message QueryFieldFailure {
|
||||||
|
repeated QueryPathPart path = 1;
|
||||||
|
string error = 2;
|
||||||
|
}
|
||||||
|
message QueryChangesRequest {
|
||||||
|
string query_id = 1;
|
||||||
|
oneof root {
|
||||||
|
string object_id = 2;
|
||||||
|
quixos.WorkspaceQueryScope workspace_scope = 4;
|
||||||
|
}
|
||||||
|
string data_version = 3;
|
||||||
|
}
|
||||||
|
message QueryChangesResponse {
|
||||||
|
bool changed = 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,4 +70,271 @@ message PersistencePlan {
|
|||||||
repeated AtomConformance conformances = 4;
|
repeated AtomConformance conformances = 4;
|
||||||
repeated StateAttachment states = 5;
|
repeated StateAttachment states = 5;
|
||||||
repeated EdgeAttachment edges = 6;
|
repeated EdgeAttachment edges = 6;
|
||||||
|
repeated InstalledQuery queries = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immutable checked query IR. Installed only with the checked persistence plan;
|
||||||
|
// query callers select its ID, never send or modify these definitions.
|
||||||
|
message QueryArgument {
|
||||||
|
oneof value {
|
||||||
|
string variable = 1;
|
||||||
|
string literal_json = 2;
|
||||||
|
QueryArgumentList list = 3;
|
||||||
|
QueryArgumentObject object = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message QueryArgumentList {
|
||||||
|
repeated QueryArgument values = 1;
|
||||||
|
}
|
||||||
|
message QueryArgumentObject {
|
||||||
|
map<string, QueryArgument> fields = 1;
|
||||||
|
}
|
||||||
|
message QueryCondition {
|
||||||
|
bool include = 1;
|
||||||
|
QueryArgument value = 2;
|
||||||
|
}
|
||||||
|
message QuerySelection {
|
||||||
|
string name = 1;
|
||||||
|
string key = 2;
|
||||||
|
string interface_revision_id = 3;
|
||||||
|
string member_id = 4;
|
||||||
|
string target_interface_revision_id = 5;
|
||||||
|
repeated QueryCondition conditions = 6;
|
||||||
|
map<string, QueryArgument> arguments = 7;
|
||||||
|
repeated QuerySelection selection = 8;
|
||||||
|
QueryRelationalPlan relational = 9;
|
||||||
|
QueryPredicate predicate = 10;
|
||||||
|
// Stable checked selection identity; empty for ordinary value projections.
|
||||||
|
string live_selection_id = 11;
|
||||||
|
}
|
||||||
|
// Resolved IDs, not GraphQL names, determine execution. Response selections
|
||||||
|
// remain separate so aliases and fragments cannot change relational semantics.
|
||||||
|
message QueryPathStep {
|
||||||
|
oneof step {
|
||||||
|
bool source = 1;
|
||||||
|
bool target = 2;
|
||||||
|
QueryRelationPath relation = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message QueryRelationPath {
|
||||||
|
string interface_revision_id = 1;
|
||||||
|
string member_id = 2;
|
||||||
|
string target_interface_revision_id = 3;
|
||||||
|
bool optional = 4;
|
||||||
|
}
|
||||||
|
message QueryFieldOperand {
|
||||||
|
string interface_revision_id = 1;
|
||||||
|
string member_id = 2;
|
||||||
|
repeated string value_path = 3;
|
||||||
|
}
|
||||||
|
message QueryExpression {
|
||||||
|
repeated QueryPathStep path = 1;
|
||||||
|
oneof leaf {
|
||||||
|
QueryFieldOperand field = 2;
|
||||||
|
string ref = 3;
|
||||||
|
bool entry = 4;
|
||||||
|
bool map_key = 5;
|
||||||
|
}
|
||||||
|
string value_type_json = 6;
|
||||||
|
}
|
||||||
|
enum QueryAggregateOperator {
|
||||||
|
QUERY_AGGREGATE_OPERATOR_UNSPECIFIED = 0;
|
||||||
|
QUERY_AGGREGATE_OPERATOR_COUNT = 1;
|
||||||
|
QUERY_AGGREGATE_OPERATOR_COUNT_PRESENT = 2;
|
||||||
|
QUERY_AGGREGATE_OPERATOR_SUM = 3;
|
||||||
|
QUERY_AGGREGATE_OPERATOR_AVG = 4;
|
||||||
|
QUERY_AGGREGATE_OPERATOR_MIN = 5;
|
||||||
|
QUERY_AGGREGATE_OPERATOR_MAX = 6;
|
||||||
|
}
|
||||||
|
message QueryReduction {
|
||||||
|
QueryAggregateOperator operator = 1;
|
||||||
|
QueryExpression operand = 2;
|
||||||
|
string value_type_json = 3;
|
||||||
|
}
|
||||||
|
message QueryOperand {
|
||||||
|
oneof operand {
|
||||||
|
QueryExpression expression = 1;
|
||||||
|
QueryReduction reduction = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enum QueryComparisonOperator {
|
||||||
|
QUERY_COMPARISON_OPERATOR_UNSPECIFIED = 0;
|
||||||
|
QUERY_COMPARISON_OPERATOR_EQ = 1;
|
||||||
|
QUERY_COMPARISON_OPERATOR_IN = 2;
|
||||||
|
QUERY_COMPARISON_OPERATOR_IS_NULL = 3;
|
||||||
|
QUERY_COMPARISON_OPERATOR_LT = 4;
|
||||||
|
QUERY_COMPARISON_OPERATOR_LTE = 5;
|
||||||
|
QUERY_COMPARISON_OPERATOR_GT = 6;
|
||||||
|
QUERY_COMPARISON_OPERATOR_GTE = 7;
|
||||||
|
QUERY_COMPARISON_OPERATOR_CONTAINS_INSENSITIVE = 8;
|
||||||
|
}
|
||||||
|
message QueryComparison {
|
||||||
|
QueryOperand operand = 1;
|
||||||
|
QueryComparisonOperator operator = 2;
|
||||||
|
QueryArgument value = 3;
|
||||||
|
}
|
||||||
|
message QueryPredicateList {
|
||||||
|
repeated QueryPredicate children = 1;
|
||||||
|
}
|
||||||
|
enum QueryRelationPredicateOperator {
|
||||||
|
QUERY_RELATION_PREDICATE_OPERATOR_UNSPECIFIED = 0;
|
||||||
|
QUERY_RELATION_PREDICATE_OPERATOR_SOME = 1;
|
||||||
|
QUERY_RELATION_PREDICATE_OPERATOR_NONE = 2;
|
||||||
|
QUERY_RELATION_PREDICATE_OPERATOR_IS = 3;
|
||||||
|
QUERY_RELATION_PREDICATE_OPERATOR_IS_NULL = 4;
|
||||||
|
}
|
||||||
|
message QueryRelationPredicate {
|
||||||
|
repeated QueryPathStep path = 1;
|
||||||
|
QueryRelationPath relation = 2;
|
||||||
|
QueryRelationPredicateOperator operator = 3;
|
||||||
|
QueryPredicate predicate = 4;
|
||||||
|
QueryArgument value = 5;
|
||||||
|
}
|
||||||
|
message QueryReductionPredicate {
|
||||||
|
repeated QueryPathStep path = 1;
|
||||||
|
QueryRelationPath relation = 2;
|
||||||
|
QueryPredicate where = 3;
|
||||||
|
QueryPredicate having = 4;
|
||||||
|
}
|
||||||
|
message QueryInterfacePredicate {
|
||||||
|
string interface_revision_id = 1;
|
||||||
|
QueryPredicate predicate = 2;
|
||||||
|
}
|
||||||
|
message QueryPredicate {
|
||||||
|
oneof predicate {
|
||||||
|
QueryPredicateList and = 1;
|
||||||
|
QueryPredicateList or = 2;
|
||||||
|
QueryPredicate not = 3;
|
||||||
|
QueryComparison compare = 4;
|
||||||
|
QueryRelationPredicate relation = 5;
|
||||||
|
QueryReductionPredicate reduce = 6;
|
||||||
|
QueryInterfacePredicate as = 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message QueryRow {
|
||||||
|
oneof row {
|
||||||
|
QueryObjectRow object = 1;
|
||||||
|
QueryPairRow pair = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message QueryObjectRow {
|
||||||
|
string interface_revision_id = 1;
|
||||||
|
bool membership = 2;
|
||||||
|
string key_type = 3;
|
||||||
|
}
|
||||||
|
message QueryPairRow {
|
||||||
|
QueryRow source = 1;
|
||||||
|
QueryRow target = 2;
|
||||||
|
}
|
||||||
|
message QueryKey {
|
||||||
|
repeated string path = 1;
|
||||||
|
QueryExpression expression = 2;
|
||||||
|
}
|
||||||
|
message QueryDistinct {
|
||||||
|
repeated QueryKey keys = 1;
|
||||||
|
}
|
||||||
|
message QueryExpansion {
|
||||||
|
repeated QueryPathStep path = 1;
|
||||||
|
QueryRelationPath relation = 2;
|
||||||
|
}
|
||||||
|
message QueryRelationalStage {
|
||||||
|
uint32 id = 1;
|
||||||
|
optional uint32 input = 2;
|
||||||
|
QueryRow row = 3;
|
||||||
|
oneof operation {
|
||||||
|
QueryRelationPath source = 4;
|
||||||
|
QueryPredicate filter = 5;
|
||||||
|
QueryExpansion expand = 6;
|
||||||
|
QueryDistinct distinct = 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message QueryReductionProjection {
|
||||||
|
repeated string path = 1;
|
||||||
|
QueryReduction reduction = 2;
|
||||||
|
}
|
||||||
|
message QueryAggregateOrder {
|
||||||
|
QueryOperand operand = 1;
|
||||||
|
bool descending = 2;
|
||||||
|
}
|
||||||
|
message QueryRelationalTerminal {
|
||||||
|
uint32 stage = 1;
|
||||||
|
repeated string path = 2;
|
||||||
|
bool groups = 3;
|
||||||
|
repeated QueryKey keys = 4;
|
||||||
|
repeated QueryReductionProjection reductions = 5;
|
||||||
|
QueryPredicate where = 6;
|
||||||
|
QueryPredicate having = 7;
|
||||||
|
repeated QueryAggregateOrder order = 8;
|
||||||
|
QueryArgument first = 9;
|
||||||
|
QueryArgument all = 10;
|
||||||
|
QueryArgument after = 11;
|
||||||
|
repeated QuerySelection selection = 12;
|
||||||
|
bool residual = 13;
|
||||||
|
// Internal ordinary-relationship residual: yields ordered membership IDs.
|
||||||
|
bool rows = 14;
|
||||||
|
}
|
||||||
|
message QueryRelationalPlan {
|
||||||
|
repeated QueryRelationalStage stages = 1;
|
||||||
|
repeated QueryRelationalTerminal terminals = 2;
|
||||||
|
}
|
||||||
|
message QueryReadBinding {
|
||||||
|
string atom_id = 1;
|
||||||
|
string interface_revision_id = 2;
|
||||||
|
string member_id = 3;
|
||||||
|
string getter_operation_id = 4;
|
||||||
|
string slot_id = 5;
|
||||||
|
string edge_type_id = 6;
|
||||||
|
string projection_id = 7;
|
||||||
|
bool rpc = 8;
|
||||||
|
string watch_start_operation_id = 9;
|
||||||
|
string watch_stop_operation_id = 10;
|
||||||
|
string field_name = 11;
|
||||||
|
string value_type_json = 12;
|
||||||
|
Cardinality cardinality = 13;
|
||||||
|
string key_type = 14;
|
||||||
|
}
|
||||||
|
// Checked schema survives even when no atom currently implements an interface.
|
||||||
|
message QueryFieldContract {
|
||||||
|
string interface_revision_id = 1;
|
||||||
|
string member_id = 2;
|
||||||
|
string field_name = 3;
|
||||||
|
string value_type_json = 4;
|
||||||
|
Cardinality cardinality = 5;
|
||||||
|
string key_type = 6;
|
||||||
|
}
|
||||||
|
message QueryBudgets {
|
||||||
|
uint32 rows = 1;
|
||||||
|
uint32 depth = 2;
|
||||||
|
uint32 result_bytes = 3;
|
||||||
|
uint32 candidates = 4;
|
||||||
|
uint32 rpc_calls = 5;
|
||||||
|
uint32 concurrency = 6;
|
||||||
|
uint32 deadline_ms = 7;
|
||||||
|
}
|
||||||
|
message QueryPlacement {
|
||||||
|
oneof kind {
|
||||||
|
bool replica = 1;
|
||||||
|
string local_source_query_id = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message InstalledQuery {
|
||||||
|
string id = 1;
|
||||||
|
string definition_digest = 2;
|
||||||
|
string binding_digest = 3;
|
||||||
|
oneof root {
|
||||||
|
string object_interface = 4;
|
||||||
|
string workspace_interface = 14;
|
||||||
|
}
|
||||||
|
repeated QuerySelection selection = 5;
|
||||||
|
repeated QueryReadBinding bindings = 6;
|
||||||
|
QueryBudgets budgets = 7;
|
||||||
|
string variables_type_json = 8;
|
||||||
|
string output_type_json = 9;
|
||||||
|
map<string, QueryArgument> variable_defaults = 10;
|
||||||
|
bool watch = 11;
|
||||||
|
uint32 polling_interval_ms = 12;
|
||||||
|
bool rpc_predicate_or_order = 13;
|
||||||
|
repeated string root_atom_ids = 15;
|
||||||
|
repeated QueryFieldContract fields = 16;
|
||||||
|
QueryPlacement placement = 17;
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-7
@@ -3,11 +3,16 @@ syntax = "proto3";
|
|||||||
package quixos.orch;
|
package quixos.orch;
|
||||||
|
|
||||||
import "camino/api.proto";
|
import "camino/api.proto";
|
||||||
|
import "camino/schema.proto";
|
||||||
import "quixos/package.proto";
|
import "quixos/package.proto";
|
||||||
import "quixos/refs.proto";
|
import "quixos/refs.proto";
|
||||||
import "quixos/runtime.proto";
|
import "quixos/runtime.proto";
|
||||||
|
|
||||||
service OrchestratorRuntime {
|
service OrchestratorRuntime {
|
||||||
|
rpc ReadDefinitions(DefinitionRequest) returns (DefinitionPage);
|
||||||
|
rpc WatchDefinitions(DefinitionRequest) returns (stream DefinitionPage);
|
||||||
|
rpc ExecuteQuery(camino.QueryRequest) returns (camino.QueryResponse);
|
||||||
|
rpc WatchQuery(camino.QueryRequest) returns (stream QueryEvent);
|
||||||
rpc TryConform(TryConformRequest) returns (TryConformResponse);
|
rpc TryConform(TryConformRequest) returns (TryConformResponse);
|
||||||
rpc InvokeCapability(InvokeCapabilityRequest) returns (InvokeCapabilityResponse);
|
rpc InvokeCapability(InvokeCapabilityRequest) returns (InvokeCapabilityResponse);
|
||||||
rpc EditCapabilityField(EditCapabilityFieldRequest) returns (InvokeCapabilityResponse);
|
rpc EditCapabilityField(EditCapabilityFieldRequest) returns (InvokeCapabilityResponse);
|
||||||
@@ -23,6 +28,93 @@ service OrchestratorRuntime {
|
|||||||
rpc CloseActivation(CloseActivationRequest) returns (CloseActivationResponse);
|
rpc CloseActivation(CloseActivationRequest) returns (CloseActivationResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Activated definitions, not runtime metrics or object data. Nested type JSON
|
||||||
|
// uses the compiler's canonical ValueType/InterfaceRevision public contracts.
|
||||||
|
message DefinitionSelector {
|
||||||
|
string kind = 1; // active | atoms | interfaces | conformances | queries | indexes
|
||||||
|
repeated string ids = 2;
|
||||||
|
string atom_id = 3;
|
||||||
|
string interface_revision_id = 4;
|
||||||
|
string package_revision_id = 5;
|
||||||
|
}
|
||||||
|
message DefinitionIdentity {
|
||||||
|
string workspace_id = 1;
|
||||||
|
string workspace_revision_id = 2;
|
||||||
|
string source_root_commit = 3;
|
||||||
|
string workspace_epoch = 4;
|
||||||
|
string definition_digest = 5;
|
||||||
|
}
|
||||||
|
message DefinitionRequest {
|
||||||
|
DefinitionSelector selector = 1;
|
||||||
|
uint32 first = 2; // 1..100; 0 uses 50
|
||||||
|
string after = 3;
|
||||||
|
string snapshot = 4; // issued definition_digest; current serving snapshot only
|
||||||
|
string workspace_id = 5; // required for injected package authority
|
||||||
|
string workspace_epoch = 6; // required for invocation-bound package calls
|
||||||
|
}
|
||||||
|
message ReflectedAtom {
|
||||||
|
string id = 1;
|
||||||
|
string display_name = 2;
|
||||||
|
string constructor_input_type_json = 3;
|
||||||
|
}
|
||||||
|
message ReflectedInterface {
|
||||||
|
string id = 1;
|
||||||
|
string interface_id = 2;
|
||||||
|
string display_name = 3;
|
||||||
|
string contract_json = 4; // canonical interface contract, excluding source location
|
||||||
|
}
|
||||||
|
message ReflectedConformance {
|
||||||
|
string id = 1;
|
||||||
|
string atom_id = 2;
|
||||||
|
string interface_revision_id = 3;
|
||||||
|
string definition_id = 4;
|
||||||
|
repeated string operation_ids = 5;
|
||||||
|
repeated ClassCapability class_capabilities = 6;
|
||||||
|
bool instance = 7;
|
||||||
|
}
|
||||||
|
message ReflectedQuery {
|
||||||
|
string id = 1;
|
||||||
|
string name = 2;
|
||||||
|
string package_revision_id = 3;
|
||||||
|
string definition_digest = 4;
|
||||||
|
string binding_digest = 5;
|
||||||
|
string root_kind = 6;
|
||||||
|
string interface_revision_id = 7;
|
||||||
|
string variables_type_json = 8;
|
||||||
|
string output_type_json = 9;
|
||||||
|
string effects_json = 10;
|
||||||
|
bool watch = 11;
|
||||||
|
WorkspaceQueryScope workspace_scope = 12;
|
||||||
|
}
|
||||||
|
message ReflectedIndex {
|
||||||
|
string id = 1;
|
||||||
|
string contract_json = 2;
|
||||||
|
}
|
||||||
|
message DefinitionRecord {
|
||||||
|
oneof definition {
|
||||||
|
ReflectedAtom atom = 1;
|
||||||
|
ReflectedInterface interface = 2;
|
||||||
|
ReflectedConformance conformance = 3;
|
||||||
|
ReflectedQuery query = 4;
|
||||||
|
ReflectedIndex index = 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message DefinitionPage {
|
||||||
|
DefinitionIdentity identity = 1;
|
||||||
|
repeated DefinitionRecord records = 2;
|
||||||
|
string next_cursor = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message QueryEvent {
|
||||||
|
string run_id = 1;
|
||||||
|
uint64 sequence = 2;
|
||||||
|
string kind = 3;
|
||||||
|
camino.QueryResponse snapshot = 4;
|
||||||
|
repeated camino.QueryPathPart path = 5;
|
||||||
|
camino.Value value = 6;
|
||||||
|
string error = 7;
|
||||||
|
}
|
||||||
|
|
||||||
// Exact closed interface lookup; no policy selection or competing conformances.
|
// Exact closed interface lookup; no policy selection or competing conformances.
|
||||||
message TryConformRequest {
|
message TryConformRequest {
|
||||||
string object_id = 1;
|
string object_id = 1;
|
||||||
@@ -74,20 +166,16 @@ message InvokeCapabilityResponse {
|
|||||||
FieldEditing field_editing = 7;
|
FieldEditing field_editing = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolved from the checked native getter/setter binding, not Value.source.
|
|
||||||
message FieldEditing {
|
|
||||||
string getter_operation_id = 1;
|
|
||||||
string setter_operation_id = 2;
|
|
||||||
string document_type = 3;
|
|
||||||
string binding_digest = 4;
|
|
||||||
}
|
|
||||||
message EditCapabilityFieldRequest {
|
message EditCapabilityFieldRequest {
|
||||||
// The public getter; setter must belong to the same value member.
|
// The public getter; setter must belong to the same value member.
|
||||||
quixos.CapabilityRef capability = 1;
|
quixos.CapabilityRef capability = 1;
|
||||||
string object_id = 2;
|
string object_id = 2;
|
||||||
string setter_operation_id = 3;
|
string setter_operation_id = 3;
|
||||||
string binding_digest = 4;
|
string binding_digest = 4;
|
||||||
|
oneof edit {
|
||||||
camino.CrdtValue update = 5;
|
camino.CrdtValue update = 5;
|
||||||
|
camino.Value replacement = 7;
|
||||||
|
}
|
||||||
string client_mutation_id = 6;
|
string client_mutation_id = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +198,7 @@ message WatchCapabilityEvent {
|
|||||||
message GetWorkspaceRequest {
|
message GetWorkspaceRequest {
|
||||||
// Revision polling must not download the entire interface graph.
|
// Revision polling must not download the entire interface graph.
|
||||||
bool include_interface_contracts = 1;
|
bool include_interface_contracts = 1;
|
||||||
|
bool include_query_contracts = 2;
|
||||||
}
|
}
|
||||||
message GetWorkspaceResponse {
|
message GetWorkspaceResponse {
|
||||||
string workspace_id = 1;
|
string workspace_id = 1;
|
||||||
@@ -123,6 +212,19 @@ message GetWorkspaceResponse {
|
|||||||
// Exact closed interface contracts used by checked presentation consumers.
|
// Exact closed interface contracts used by checked presentation consumers.
|
||||||
string interfaces_json = 7;
|
string interfaces_json = 7;
|
||||||
repeated ClassCapability class_capabilities = 8;
|
repeated ClassCapability class_capabilities = 8;
|
||||||
|
repeated QueryDescription queries = 9;
|
||||||
|
uint32 active_query_executions = 10;
|
||||||
|
}
|
||||||
|
message QueryDescription {
|
||||||
|
string id = 1;
|
||||||
|
string name = 2;
|
||||||
|
string package_revision_id = 3;
|
||||||
|
string document = 4;
|
||||||
|
string schema = 5;
|
||||||
|
repeated string source_files = 6;
|
||||||
|
string effects_json = 7;
|
||||||
|
camino.InstalledQuery plan = 8;
|
||||||
|
WorkspaceQueryScope workspace_scope = 9;
|
||||||
}
|
}
|
||||||
message ClassCapability {
|
message ClassCapability {
|
||||||
string conformance_id = 1;
|
string conformance_id = 1;
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ message CapabilityRef {
|
|||||||
ConformanceWitness conformance = 3;
|
ConformanceWitness conformance = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolved native editing semantics. A source snapshot alone grants no writer.
|
||||||
|
message FieldEditing {
|
||||||
|
string getter_operation_id = 1;
|
||||||
|
string setter_operation_id = 2;
|
||||||
|
string document_type = 3;
|
||||||
|
string binding_digest = 4;
|
||||||
|
enum Mode {
|
||||||
|
UNSPECIFIED = 0;
|
||||||
|
REGISTER = 1;
|
||||||
|
CRDT = 2;
|
||||||
|
}
|
||||||
|
Mode mode = 5;
|
||||||
|
}
|
||||||
|
|
||||||
message ConformanceWitness {
|
message ConformanceWitness {
|
||||||
string object_id = 1;
|
string object_id = 1;
|
||||||
string interface_revision_id = 2;
|
string interface_revision_id = 2;
|
||||||
@@ -29,12 +43,35 @@ message InjectedDependency {
|
|||||||
EdgeDependency edge = 3;
|
EdgeDependency edge = 3;
|
||||||
string interface_revision_id = 4;
|
string interface_revision_id = 4;
|
||||||
string constructor_atom_id = 5;
|
string constructor_atom_id = 5;
|
||||||
|
string query_id = 7;
|
||||||
|
WorkspaceQueryDependency workspace_query = 8;
|
||||||
|
ReflectionDependency reflection = 9;
|
||||||
}
|
}
|
||||||
// Defaults to the invocation receiver. A checked dependency traversal can
|
// Defaults to the invocation receiver. A checked dependency traversal can
|
||||||
// select a related object explicitly before the package runtime starts.
|
// select a related object explicitly before the package runtime starts.
|
||||||
string object_id = 6;
|
string object_id = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ReflectionDependency {
|
||||||
|
string workspace_id = 1;
|
||||||
|
string workspace_epoch = 2;
|
||||||
|
repeated string selectors = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope evidence is not authority by itself. The caller must also hold the
|
||||||
|
// matching workspace-query grant (or the authenticated workspace user grant).
|
||||||
|
message WorkspaceQueryScope {
|
||||||
|
string workspace_id = 1;
|
||||||
|
string workspace_revision_id = 2;
|
||||||
|
string workspace_epoch = 3;
|
||||||
|
string interface_revision_id = 4;
|
||||||
|
string binding_digest = 5;
|
||||||
|
}
|
||||||
|
message WorkspaceQueryDependency {
|
||||||
|
string query_id = 1;
|
||||||
|
WorkspaceQueryScope scope = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message EdgeDependency {
|
message EdgeDependency {
|
||||||
string edge_type_id = 1;
|
string edge_type_id = 1;
|
||||||
string projection_id = 2;
|
string projection_id = 2;
|
||||||
|
|||||||
+22
-2
@@ -1,6 +1,10 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { readFile, writeFile } from "node:fs/promises";
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
import { generateTypeScriptBindings } from "./index.js";
|
import {
|
||||||
|
generateTypeScriptBindings,
|
||||||
|
generatePortableTypeScriptBindings,
|
||||||
|
generateRemoteTypeScriptBindings,
|
||||||
|
} from "./index.js";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { reactPlatformTypes } from "./react-platform.js";
|
import { reactPlatformTypes } from "./react-platform.js";
|
||||||
import { generateReactBindings } from "./react.js";
|
import { generateReactBindings } from "./react.js";
|
||||||
@@ -11,7 +15,23 @@ const main = async () => {
|
|||||||
throw new Error("usage: quixos-codegen-ts SCHEMA.json PACKAGE_REVISION OUTPUT.ts [OPTIONS.json]");
|
throw new Error("usage: quixos-codegen-ts SCHEMA.json PACKAGE_REVISION OUTPUT.ts [OPTIONS.json]");
|
||||||
const config = options ? JSON.parse(await readFile(options, "utf8")) : {};
|
const config = options ? JSON.parse(await readFile(options, "utf8")) : {};
|
||||||
const contracts = JSON.parse(await readFile(schema, "utf8"));
|
const contracts = JSON.parse(await readFile(schema, "utf8"));
|
||||||
const generated = generateTypeScriptBindings(contracts, revision, config);
|
if ([config.portable, config.remote, config.react].filter(Boolean).length > 1)
|
||||||
|
throw new Error("Portable, remote and React bindings are separate artifacts");
|
||||||
|
if (config.remote !== undefined && config.remote !== true) throw new Error("Remote binding mode must be true");
|
||||||
|
if (config.portable && typeof config.portable.target !== "string")
|
||||||
|
throw new Error("Portable bindings require an explicit target");
|
||||||
|
const generated = config.remote
|
||||||
|
? generateRemoteTypeScriptBindings(
|
||||||
|
contracts,
|
||||||
|
revision,
|
||||||
|
config.runtimeModule ? { runtimeModule: config.runtimeModule } : {},
|
||||||
|
)
|
||||||
|
: config.portable
|
||||||
|
? generatePortableTypeScriptBindings(contracts, revision, {
|
||||||
|
...config.portable,
|
||||||
|
...(config.runtimeModule ? { runtimeModule: config.runtimeModule } : {}),
|
||||||
|
})
|
||||||
|
: generateTypeScriptBindings(contracts, revision, config);
|
||||||
await writeFile(output, generated);
|
await writeFile(output, generated);
|
||||||
if (config.react) {
|
if (config.react) {
|
||||||
await writeFile(path.join(path.dirname(output), "web-studio-react-runtime.d.ts"), reactPlatformTypes);
|
await writeFile(path.join(path.dirname(output), "web-studio-react-runtime.d.ts"), reactPlatformTypes);
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export const generateClientContracts = (
|
|||||||
throw new Error("Host contracts require closed interface applications, not generic definitions");
|
throw new Error("Host contracts require closed interface applications, not generic definitions");
|
||||||
const type = (value: ValueType): string => {
|
const type = (value: ValueType): string => {
|
||||||
switch (value.kind) {
|
switch (value.kind) {
|
||||||
|
case "variant":
|
||||||
|
throw new Error("Variants require the Camino portable value runtime; legacy RPC bindings are unsupported");
|
||||||
case "builtin":
|
case "builtin":
|
||||||
return value.name === "unit" ? "undefined" : "string";
|
return value.name === "unit" ? "undefined" : "string";
|
||||||
case "scalar":
|
case "scalar":
|
||||||
@@ -28,6 +30,8 @@ export const generateClientContracts = (
|
|||||||
return qualified
|
return qualified
|
||||||
? `CapabilityReference<${JSON.stringify(value.expectation.kind === "atom" ? `atom:${value.expectation.atomId}` : `interface:${value.expectation.interfaceRevisionId}`)}>`
|
? `CapabilityReference<${JSON.stringify(value.expectation.kind === "atom" ? `atom:${value.expectation.atomId}` : `interface:${value.expectation.interfaceRevisionId}`)}>`
|
||||||
: `{readonly $quixosRef: string}`;
|
: `{readonly $quixosRef: string}`;
|
||||||
|
case "change":
|
||||||
|
throw new Error("CRDT change values require the prepared-change codec and runtime (LF-19)");
|
||||||
case "optional":
|
case "optional":
|
||||||
return `(${type(value.value)} | null)`;
|
return `(${type(value.value)} | null)`;
|
||||||
case "list":
|
case "list":
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export const genericImplementationType = (
|
|||||||
definition: GenericPackageExport,
|
definition: GenericPackageExport,
|
||||||
interfaces: InterfaceRevision[],
|
interfaces: InterfaceRevision[],
|
||||||
concrete: (type: ValueType) => string,
|
concrete: (type: ValueType) => string,
|
||||||
|
queryPort: (requirement: Extract<GenericDependencyPort["requirement"], { kind: "query" }>) => string = () => {
|
||||||
|
throw new Error("Query binding schema required");
|
||||||
|
},
|
||||||
): string => {
|
): string => {
|
||||||
const names = new Map(definition.parameters.map((parameter, index) => [parameter.id, `T${index}`]));
|
const names = new Map(definition.parameters.map((parameter, index) => [parameter.id, `T${index}`]));
|
||||||
type Scope = { arguments: Map<string, string>; aliases: ValueAliasDefinition[] };
|
type Scope = { arguments: Map<string, string>; aliases: ValueAliasDefinition[] };
|
||||||
@@ -46,6 +49,8 @@ export const genericImplementationType = (
|
|||||||
const value = (type: ValueTypeExpression, scope: Scope, depth = 0): string => {
|
const value = (type: ValueTypeExpression, scope: Scope, depth = 0): string => {
|
||||||
if (depth > 128) throw new Error("Generic type expansion exceeds depth limit");
|
if (depth > 128) throw new Error("Generic type expansion exceeds depth limit");
|
||||||
switch (type.kind) {
|
switch (type.kind) {
|
||||||
|
case "variant":
|
||||||
|
throw new Error("Variants require the Camino portable value runtime; legacy RPC bindings are unsupported");
|
||||||
case "parameter": {
|
case "parameter": {
|
||||||
const bound = scope.arguments.get(type.parameterId);
|
const bound = scope.arguments.get(type.parameterId);
|
||||||
if (bound) return bound;
|
if (bound) return bound;
|
||||||
@@ -57,6 +62,8 @@ export const genericImplementationType = (
|
|||||||
return `QxObjectRef<${target(type.expectation, scope)}>`;
|
return `QxObjectRef<${target(type.expectation, scope)}>`;
|
||||||
case "record":
|
case "record":
|
||||||
return object(Object.entries(type.fields).map(([name, field]) => [name, value(field, scope, depth + 1)]));
|
return object(Object.entries(type.fields).map(([name, field]) => [name, value(field, scope, depth + 1)]));
|
||||||
|
case "change":
|
||||||
|
throw new Error("CRDT change values require the prepared-change codec and runtime (LF-19)");
|
||||||
case "optional":
|
case "optional":
|
||||||
return `(${value(type.value, scope, depth + 1)} | null)`;
|
return `(${value(type.value, scope, depth + 1)} | null)`;
|
||||||
case "list":
|
case "list":
|
||||||
@@ -76,6 +83,8 @@ export const genericImplementationType = (
|
|||||||
const requirement = entry.requirement,
|
const requirement = entry.requirement,
|
||||||
scope = rootScope();
|
scope = rootScope();
|
||||||
switch (requirement.kind) {
|
switch (requirement.kind) {
|
||||||
|
case "reflection":
|
||||||
|
return `QxReflectionPort<${requirement.selectors.map((value) => JSON.stringify(value)).join(" | ")}>`;
|
||||||
case "state":
|
case "state":
|
||||||
return object([
|
return object([
|
||||||
...requirement.primitives.map((primitive): [string, string] => {
|
...requirement.primitives.map((primitive): [string, string] => {
|
||||||
@@ -108,6 +117,10 @@ export const genericImplementationType = (
|
|||||||
]);
|
]);
|
||||||
return object(methods);
|
return object(methods);
|
||||||
}
|
}
|
||||||
|
case "query":
|
||||||
|
return queryPort(requirement);
|
||||||
|
case "creation":
|
||||||
|
throw new Error("Creation ports require the portable transaction context generator (LF-09)");
|
||||||
case "constructor":
|
case "constructor":
|
||||||
return object([
|
return object([
|
||||||
[
|
[
|
||||||
@@ -124,7 +137,12 @@ export const genericImplementationType = (
|
|||||||
};
|
};
|
||||||
const operations = (contract.template?.members ?? contract.members).flatMap((member) =>
|
const operations = (contract.template?.members ?? contract.members).flatMap((member) =>
|
||||||
member.operations
|
member.operations
|
||||||
.filter((op) => op.mode === "call" && op.scope !== "class")
|
.filter(
|
||||||
|
(op) =>
|
||||||
|
op.mode === "call" &&
|
||||||
|
op.scope !== "class" &&
|
||||||
|
(!requirement.operations || requirement.operations.includes(op.id)),
|
||||||
|
)
|
||||||
.map((op) => ({ ...op, name: `${member.displayName}.${op.displayName}` })),
|
.map((op) => ({ ...op, name: `${member.displayName}.${op.displayName}` })),
|
||||||
);
|
);
|
||||||
const methods: [string, string][] = [
|
const methods: [string, string][] = [
|
||||||
|
|||||||
+113
-9
@@ -9,6 +9,14 @@ import type { CompiledCapabilityResourceRepository } from "../capability-languag
|
|||||||
import { genericImplementationType } from "./generics.js";
|
import { genericImplementationType } from "./generics.js";
|
||||||
import { capabilityId } from "../capability-model/types.js";
|
import { capabilityId } from "../capability-model/types.js";
|
||||||
|
|
||||||
|
/** Source-free package compilation inputs. Nominal references do not import
|
||||||
|
* implementations or concrete workspace conformances. */
|
||||||
|
export type BindingContractSchema = {
|
||||||
|
format: "quixos-bindings";
|
||||||
|
version: 1;
|
||||||
|
interfaces: Omit<InterfaceRevision, "source">[];
|
||||||
|
packages: Pick<PackageRevision, "revisionId" | "exports" | "genericExports">[];
|
||||||
|
};
|
||||||
/** Portable generator input. New backends consume this instead of the parser or TS runtime. */
|
/** Portable generator input. New backends consume this instead of the parser or TS runtime. */
|
||||||
export type BindingSchema = {
|
export type BindingSchema = {
|
||||||
format: "quixos-bindings";
|
format: "quixos-bindings";
|
||||||
@@ -17,24 +25,29 @@ export type BindingSchema = {
|
|||||||
interfaceTemplates?: InterfaceRevision[];
|
interfaceTemplates?: InterfaceRevision[];
|
||||||
packages: PackageRevision[];
|
packages: PackageRevision[];
|
||||||
};
|
};
|
||||||
export const bindingSchema = (compiled: Pick<CompiledCapabilityResourceRepository, "resources">): BindingSchema => ({
|
export const bindingSchema = (
|
||||||
|
compiled: Pick<CompiledCapabilityResourceRepository, "resources"> &
|
||||||
|
Partial<Pick<CompiledCapabilityResourceRepository, "capabilityResources">>,
|
||||||
|
): BindingSchema => ({
|
||||||
format: "quixos-bindings",
|
format: "quixos-bindings",
|
||||||
version: 1,
|
version: 1,
|
||||||
interfaceTemplates: compiled.resources.flatMap((node) =>
|
interfaceTemplates: (compiled.capabilityResources ?? compiled.resources).flatMap((node) =>
|
||||||
node.resource.kind === "interface" && node.resource.revision.template ? [node.resource.revision] : [],
|
node.resource.kind === "interface" && node.resource.revision.template ? [node.resource.revision] : [],
|
||||||
),
|
),
|
||||||
interfaces: [
|
interfaces: [
|
||||||
...new Map(
|
...new Map(
|
||||||
compiled.resources
|
(compiled.capabilityResources ?? compiled.resources)
|
||||||
.flatMap((node) => [
|
.flatMap((node) => [
|
||||||
...(node.resource.kind === "interface" ? [node.resource.revision] : []),
|
...(node.resource.kind === "interface" ? [node.resource.revision] : []),
|
||||||
...(node.resource.specializations ?? []),
|
...(node.resource.kind === "build" ? [] : (node.resource.specializations ?? [])),
|
||||||
])
|
])
|
||||||
.filter((entry) => !entry.template)
|
.filter((entry) => !entry.template)
|
||||||
.map((entry) => [entry.revisionId, entry]),
|
.map((entry) => [entry.revisionId, entry]),
|
||||||
).values(),
|
).values(),
|
||||||
],
|
],
|
||||||
packages: compiled.resources.flatMap((node) => (node.resource.kind === "package" ? [node.resource.revision] : [])),
|
packages: (compiled.capabilityResources ?? compiled.resources).flatMap((node) =>
|
||||||
|
node.resource.kind === "package" ? [node.resource.revision] : [],
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const specializeBindingSchema = (
|
export const specializeBindingSchema = (
|
||||||
@@ -107,6 +120,8 @@ export const generateTypeScriptBindings = (
|
|||||||
const messages = new Map<string, string>();
|
const messages = new Map<string, string>();
|
||||||
const type = (value: ValueType): string => {
|
const type = (value: ValueType): string => {
|
||||||
switch (value.kind) {
|
switch (value.kind) {
|
||||||
|
case "variant":
|
||||||
|
throw new Error("Variants require the Camino portable value runtime; legacy RPC bindings are unsupported");
|
||||||
case "builtin":
|
case "builtin":
|
||||||
return value.name === "unit" ? "null" : "QxWatchHandle";
|
return value.name === "unit" ? "null" : "QxWatchHandle";
|
||||||
case "scalar":
|
case "scalar":
|
||||||
@@ -122,6 +137,8 @@ export const generateTypeScriptBindings = (
|
|||||||
}[value.name];
|
}[value.name];
|
||||||
case "object-ref":
|
case "object-ref":
|
||||||
return `QxObjectRef<${q(value.expectation.kind === "atom" ? `atom:${value.expectation.atomId}` : `interface:${value.expectation.interfaceRevisionId}`)}>`;
|
return `QxObjectRef<${q(value.expectation.kind === "atom" ? `atom:${value.expectation.atomId}` : `interface:${value.expectation.interfaceRevisionId}`)}>`;
|
||||||
|
case "change":
|
||||||
|
throw new Error("CRDT change values require the prepared-change codec and runtime (LF-19)");
|
||||||
case "optional":
|
case "optional":
|
||||||
return `(${type(value.value)} | null)`;
|
return `(${type(value.value)} | null)`;
|
||||||
case "list":
|
case "list":
|
||||||
@@ -144,6 +161,38 @@ export const generateTypeScriptBindings = (
|
|||||||
const port = (entry: DependencyPort): { type: string; spec: unknown } => {
|
const port = (entry: DependencyPort): { type: string; spec: unknown } => {
|
||||||
const requirement = entry.requirement;
|
const requirement = entry.requirement;
|
||||||
switch (requirement.kind) {
|
switch (requirement.kind) {
|
||||||
|
case "reflection":
|
||||||
|
return {
|
||||||
|
type: `QxReflectionPort<${requirement.selectors.map((value) => q(value)).join(" | ")}>`,
|
||||||
|
spec: { kind: "reflection", id: entry.id, selectors: requirement.selectors },
|
||||||
|
};
|
||||||
|
case "query": {
|
||||||
|
const query = schema.packages
|
||||||
|
.find((candidate) => candidate.revisionId === requirement.packageRevisionId)
|
||||||
|
?.checkedQueries?.find((query) => query.declaration.id === requirement.queryId);
|
||||||
|
if (!query) throw new Error(`Missing checked query ${requirement.packageRevisionId}:${requirement.queryId}`);
|
||||||
|
return {
|
||||||
|
type: object([
|
||||||
|
["execute", `(variables: ${type(query.variables)}) => Promise<${type(query.output)}>`],
|
||||||
|
...(query.declaration.watch
|
||||||
|
? ([
|
||||||
|
[
|
||||||
|
"watch",
|
||||||
|
`(variables: ${type(query.variables)}, signal: AbortSignal) => AsyncIterable<QxQuerySnapshot<${type(query.output)}>>`,
|
||||||
|
],
|
||||||
|
] as [string, string][])
|
||||||
|
: []),
|
||||||
|
]),
|
||||||
|
spec: {
|
||||||
|
kind: "query",
|
||||||
|
id: entry.id,
|
||||||
|
definitionDigest: query.definitionDigest,
|
||||||
|
variables: query.variables,
|
||||||
|
output: query.output,
|
||||||
|
watch: query.declaration.watch,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
case "state": {
|
case "state": {
|
||||||
const methods = requirement.primitives.map((primitive): [string, string] => {
|
const methods = requirement.primitives.map((primitive): [string, string] => {
|
||||||
if (primitive === "read") return ["get", `() => Promise<${type(requirement.valueType)}>`];
|
if (primitive === "read") return ["get", `() => Promise<${type(requirement.valueType)}>`];
|
||||||
@@ -181,7 +230,12 @@ export const generateTypeScriptBindings = (
|
|||||||
// Streaming ports need a future streaming ABI; ordinary calls are fully typed today.
|
// Streaming ports need a future streaming ABI; ordinary calls are fully typed today.
|
||||||
const operations = contract.members.flatMap((member) =>
|
const operations = contract.members.flatMap((member) =>
|
||||||
member.operations
|
member.operations
|
||||||
.filter((operation) => operation.mode === "call" && operation.scope !== "class")
|
.filter(
|
||||||
|
(operation) =>
|
||||||
|
operation.mode === "call" &&
|
||||||
|
operation.scope !== "class" &&
|
||||||
|
(!requirement.operations || requirement.operations.includes(operation.id)),
|
||||||
|
)
|
||||||
.map((operation) => ({ ...operation, name: `${member.displayName}.${operation.displayName}` })),
|
.map((operation) => ({ ...operation, name: `${member.displayName}.${operation.displayName}` })),
|
||||||
);
|
);
|
||||||
const methods: [string, string][] = [
|
const methods: [string, string][] = [
|
||||||
@@ -212,6 +266,8 @@ export const generateTypeScriptBindings = (
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "creation":
|
||||||
|
throw new Error("Creation ports require the portable transaction context generator (LF-09)");
|
||||||
case "constructor": {
|
case "constructor": {
|
||||||
const input = requirement.inputType;
|
const input = requirement.inputType;
|
||||||
if (!input)
|
if (!input)
|
||||||
@@ -282,10 +338,32 @@ export const generateTypeScriptBindings = (
|
|||||||
for (const definition of pkg.genericExports ?? []) {
|
for (const definition of pkg.genericExports ?? []) {
|
||||||
handlers.push([
|
handlers.push([
|
||||||
definition.displayName,
|
definition.displayName,
|
||||||
genericImplementationType(definition, [...schema.interfaces, ...(schema.interfaceTemplates ?? [])], type),
|
genericImplementationType(
|
||||||
|
definition,
|
||||||
|
[...schema.interfaces, ...(schema.interfaceTemplates ?? [])],
|
||||||
|
type,
|
||||||
|
(requirement) => port({ id: capabilityId.dependencyPort("query"), displayName: "query", requirement }).type,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
const contracts = schema.interfaces.map((iface) => {
|
// Unused imported interfaces must not introduce new message-codec obligations.
|
||||||
|
// A used port still fails above if its own required codec is missing.
|
||||||
|
const hasCodec = (value: ValueType): boolean => {
|
||||||
|
if (value.kind === "message") return !!options.messages?.[value.descriptorId];
|
||||||
|
if (value.kind === "variant") return false;
|
||||||
|
if (value.kind === "record") return Object.values(value.fields).every(hasCodec);
|
||||||
|
if (value.kind === "list" || value.kind === "optional") return hasCodec(value.value);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const contracts = schema.interfaces
|
||||||
|
.filter((iface) =>
|
||||||
|
iface.members.every((member) =>
|
||||||
|
member.operations
|
||||||
|
.filter((operation) => operation.mode === "call" && operation.scope !== "class")
|
||||||
|
.every((operation) => hasCodec(operation.inputType) && hasCodec(operation.outputType)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((iface) => {
|
||||||
const generated = port({
|
const generated = port({
|
||||||
id: capabilityId.dependencyPort("contract"),
|
id: capabilityId.dependencyPort("contract"),
|
||||||
displayName: iface.displayName,
|
displayName: iface.displayName,
|
||||||
@@ -304,7 +382,19 @@ export const generateTypeScriptBindings = (
|
|||||||
throw new Error(`Invalid message binding export ${binding.export}`);
|
throw new Error(`Invalid message binding export ${binding.export}`);
|
||||||
return `import { ${binding.export} as ${alias} } from ${q(binding.module)};`;
|
return `import { ${binding.export} as ${alias} } from ${q(binding.module)};`;
|
||||||
});
|
});
|
||||||
const signatures = `${object(contexts)} ${object(handlers)} ${object(results)} ${contracts.map((entry) => entry.type).join(" ")}`;
|
const queryVariables = object(
|
||||||
|
(pkg.checkedQueries ?? []).map((query) => [query.declaration.displayName, type(query.variables)]),
|
||||||
|
);
|
||||||
|
const queryResults = object(
|
||||||
|
(pkg.checkedQueries ?? []).map((query) => [query.declaration.displayName, type(query.output)]),
|
||||||
|
);
|
||||||
|
const queryDescriptors = object(
|
||||||
|
(pkg.checkedQueries ?? []).map((query) => [
|
||||||
|
query.declaration.displayName,
|
||||||
|
`QxQueryDescriptor<QueryVariables[${q(query.declaration.displayName)}], QueryResults[${q(query.declaration.displayName)}], ${q(query.declaration.root)}>`,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const signatures = `${object(contexts)} ${object(handlers)} ${object(results)} ${queryVariables} ${queryResults} ${queryDescriptors} ${contracts.map((entry) => entry.type).join(" ")}`;
|
||||||
const typeImports = [
|
const typeImports = [
|
||||||
"BindingValue",
|
"BindingValue",
|
||||||
"QxObjectRef",
|
"QxObjectRef",
|
||||||
@@ -317,6 +407,9 @@ export const generateTypeScriptBindings = (
|
|||||||
"QxLiveValue",
|
"QxLiveValue",
|
||||||
"RelationshipCollection",
|
"RelationshipCollection",
|
||||||
"RelationshipEntry",
|
"RelationshipEntry",
|
||||||
|
"QxQuerySnapshot",
|
||||||
|
"QxReflectionPort",
|
||||||
|
"QxQueryDescriptor",
|
||||||
].filter((name) => new RegExp(`\\b${name}\\b`).test(signatures));
|
].filter((name) => new RegExp(`\\b${name}\\b`).test(signatures));
|
||||||
return (
|
return (
|
||||||
`// Generated by quixos-codegen-ts. Do not edit. Binding ABI version 1.\n` +
|
`// Generated by quixos-codegen-ts. Do not edit. Binding ABI version 1.\n` +
|
||||||
@@ -325,6 +418,8 @@ export const generateTypeScriptBindings = (
|
|||||||
`\nexport const packageRevisionId = ${q(pkg.revisionId)};\n` +
|
`\nexport const packageRevisionId = ${q(pkg.revisionId)};\n` +
|
||||||
`declare const appliedType: unique symbol;\ntype QxApplied<Definition extends string, Arguments extends readonly unknown[]> = string & {readonly [appliedType]: (value: [Definition, Arguments]) => [Definition, Arguments]};\n` +
|
`declare const appliedType: unique symbol;\ntype QxApplied<Definition extends string, Arguments extends readonly unknown[]> = string & {readonly [appliedType]: (value: [Definition, Arguments]) => [Definition, Arguments]};\n` +
|
||||||
`export type Contexts = ${object(contexts)};\nexport type Results = ${object(results)};\nexport type Implementation = ${object(handlers)};\n` +
|
`export type Contexts = ${object(contexts)};\nexport type Results = ${object(results)};\nexport type Implementation = ${object(handlers)};\n` +
|
||||||
|
`export type QueryVariables = ${queryVariables};\nexport type QueryResults = ${queryResults};\n` +
|
||||||
|
`export const queries: ${queryDescriptors} = ${JSON.stringify(Object.fromEntries((pkg.checkedQueries ?? []).map((query) => [query.declaration.displayName, { id: `${pkg.revisionId}:${query.declaration.id}`, definitionDigest: query.definitionDigest, root: query.declaration.root, variables: query.variables, output: query.output, watch: query.declaration.watch }])), null, 2)} as const;\n` +
|
||||||
`export const contracts = {${contracts.map((entry) => `${q(entry.iface.revisionId)}: ${entry.code}`).join(",\n")}};\n` +
|
`export const contracts = {${contracts.map((entry) => `${q(entry.iface.revisionId)}: ${entry.code}`).join(",\n")}};\n` +
|
||||||
`export const interfaces = {${contracts
|
`export const interfaces = {${contracts
|
||||||
.filter((entry) => contracts.filter((other) => other.iface.displayName === entry.iface.displayName).length === 1)
|
.filter((entry) => contracts.filter((other) => other.iface.displayName === entry.iface.displayName).length === 1)
|
||||||
@@ -342,3 +437,12 @@ export const generateTypeScriptBindings = (
|
|||||||
`\n },\n});\n`
|
`\n },\n});\n`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
generatePortableTypeScriptBindings,
|
||||||
|
generateRemoteTypeScriptBindings,
|
||||||
|
portableValueSchema,
|
||||||
|
PortableBindingError,
|
||||||
|
type PortableBindingOptions,
|
||||||
|
} from "./portable.js";
|
||||||
|
export * from "./portable-client.js";
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { generatePortableClientBindings } from "./portable-client.js";
|
||||||
|
async function main() {
|
||||||
|
const [plan, output, target = "target:org.quixos.execution.javascript@1", ...rest] = process.argv.slice(2);
|
||||||
|
if (!plan || !output || rest.length)
|
||||||
|
throw Error("usage: quixos-codegen-client EXECUTION_PLAN.json OUTPUT.ts [TARGET]");
|
||||||
|
await writeFile(output, generatePortableClientBindings(JSON.parse(await readFile(plan, "utf8")), { target }));
|
||||||
|
}
|
||||||
|
void main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { compileKernelProgram } from "../execution-world/kernel.js";
|
||||||
|
import { hexDigest, type ExecutionPlan } from "../execution-world/plan.js";
|
||||||
|
import { resultInterfaces } from "../execution-world/projections.js";
|
||||||
|
import { portableValueSchema, tsType, PortableBindingError } from "./portable.js";
|
||||||
|
import type { QuerySelection } from "../query/types.js";
|
||||||
|
const conditional = (selections: readonly QuerySelection[]): boolean =>
|
||||||
|
selections.some((selection) => selection.conditions.length > 0 || conditional(selection.selection));
|
||||||
|
/** Closed-world app descriptors. Handler bindings and application acquisition share the same public identities. */
|
||||||
|
export function generatePortableClientBindings(
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
options: { target: string; runtimeModule?: string },
|
||||||
|
): string {
|
||||||
|
if (hexDigest(plan.definition) !== plan.digest) throw new PortableBindingError("client", "Invalid plan digest");
|
||||||
|
const executionPlan = (compileKernelProgram(plan) as { plan: string }).plan;
|
||||||
|
const workspace = plan.definition.workspace,
|
||||||
|
atoms = new Map<string, string[]>(),
|
||||||
|
unavailable: { key: string; reason: string }[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const operation of plan.definition.operations) {
|
||||||
|
if (operation.execution.kind === "remote" || !operation.availableTargets.includes(options.target)) {
|
||||||
|
unavailable.push({ key: operation.key, reason: "OPERATION_NOT_PORTABLE" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let input: string, output: string;
|
||||||
|
try {
|
||||||
|
input = tsType(operation.input);
|
||||||
|
output = tsType(operation.output);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof PortableBindingError)) throw error;
|
||||||
|
unavailable.push({ key: operation.key, reason: "UNSUPPORTED_OPERATION_CODEC" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const atom = workspace.atoms.find((a) => a.id === operation.atomId)!;
|
||||||
|
const iface = workspace.interfaceImports.find((i) => i.revisionId === operation.interfaceRevisionId)!;
|
||||||
|
const member = iface.members.find((m) => m.operations.some((o) => o.id === operation.operationId))!;
|
||||||
|
const facet = member.operations.find((o) => o.id === operation.operationId)!;
|
||||||
|
const name = `${iface.displayName}.${member.displayName}.${facet.displayName}`;
|
||||||
|
const alias = JSON.stringify([atom.displayName, name]);
|
||||||
|
if (seen.has(alias)) throw new PortableBindingError(alias, "Ambiguous application descriptor alias");
|
||||||
|
seen.add(alias);
|
||||||
|
const roots = resultInterfaces(plan, operation.key);
|
||||||
|
const descriptor = {
|
||||||
|
key: operation.key,
|
||||||
|
plan: executionPlan,
|
||||||
|
effect: operation.execution.kind,
|
||||||
|
input: portableValueSchema(operation.input),
|
||||||
|
output: portableValueSchema(operation.output),
|
||||||
|
resultInterfaces: roots,
|
||||||
|
};
|
||||||
|
const list = atoms.get(atom.displayName) ?? [];
|
||||||
|
list.push(
|
||||||
|
`${JSON.stringify(name)}: defineOperationDescriptor<${input}, ${output}, ${roots.map((r) => JSON.stringify(r)).join(" | ") || "never"}, ${JSON.stringify(operation.execution.kind)}>(${JSON.stringify(descriptor)})`,
|
||||||
|
);
|
||||||
|
atoms.set(atom.displayName, list);
|
||||||
|
}
|
||||||
|
const queries = new Map<string, string[]>();
|
||||||
|
const views = new Map<string, string[]>();
|
||||||
|
const serverProjections = new Map<string, string[]>();
|
||||||
|
const results = new Map<string, string[]>();
|
||||||
|
for (const query of plan.definition.queries) {
|
||||||
|
const declaration = query.query.checked.declaration;
|
||||||
|
const pkg = workspace.packageImports.find((p) => p.revisionId === query.query.packageRevisionId)!;
|
||||||
|
const alias = JSON.stringify([pkg.displayName, declaration.displayName]);
|
||||||
|
if (seen.has(`query:${alias}`)) throw new PortableBindingError(alias, "Ambiguous query alias");
|
||||||
|
seen.add(`query:${alias}`);
|
||||||
|
const addResult = () => {
|
||||||
|
if (conditional(query.query.checked.selection)) {
|
||||||
|
unavailable.push({ key: query.id, reason: "CONDITIONAL_RESULT_DECODER_UNSUPPORTED" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const decoders = results.get(pkg.displayName) ?? [];
|
||||||
|
decoders.push(
|
||||||
|
`${JSON.stringify(declaration.displayName)}: (data: Json): ${tsType(query.query.checked.output)} => decodeArgument(${JSON.stringify(portableValueSchema(query.query.checked.output))}, { codec: "camino-value-v1", usage: "argument", value: data }, { workspace: workspaceId(${JSON.stringify(workspace.workspaceId)}), limits: { bytes: ${declaration.budgets.resultBytes + 128}, nodes: 100000, depth: 64 } }) as ${tsType(query.query.checked.output)}`,
|
||||||
|
);
|
||||||
|
results.set(pkg.displayName, decoders);
|
||||||
|
};
|
||||||
|
if (!declaration.placement) {
|
||||||
|
const operations = query.operations.map(
|
||||||
|
(key) => plan.definition.operations.find((operation) => operation.key === key)!,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
operations.some(
|
||||||
|
(operation) => operation.execution.kind !== "snapshot" || !operation.execution.targets.includes("server"),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
unavailable.push({ key: query.id, reason: "QUERY_NOT_SNAPSHOT_PROJECTION" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const list = serverProjections.get(pkg.displayName) ?? [];
|
||||||
|
list.push(
|
||||||
|
JSON.stringify(declaration.displayName) +
|
||||||
|
": " +
|
||||||
|
JSON.stringify({
|
||||||
|
kind: "server-projection",
|
||||||
|
id: query.id,
|
||||||
|
plan: executionPlan,
|
||||||
|
root: declaration.root.interface,
|
||||||
|
rootKind: declaration.root.kind,
|
||||||
|
}) +
|
||||||
|
" as const satisfies ServerProjection<" +
|
||||||
|
JSON.stringify(declaration.root.interface) +
|
||||||
|
", " +
|
||||||
|
JSON.stringify(declaration.root.kind) +
|
||||||
|
">",
|
||||||
|
);
|
||||||
|
serverProjections.set(pkg.displayName, list);
|
||||||
|
addResult();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (declaration.placement.kind === "local") {
|
||||||
|
const list = views.get(pkg.displayName) ?? [];
|
||||||
|
list.push(
|
||||||
|
`${JSON.stringify(declaration.displayName)}: ${JSON.stringify({ id: query.id, plan: executionPlan, source: JSON.stringify([query.query.packageRevisionId, declaration.placement.sourceQueryId]) })} as const satisfies LocalQueryDescriptor`,
|
||||||
|
);
|
||||||
|
views.set(pkg.displayName, list);
|
||||||
|
addResult();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const list = queries.get(pkg.displayName) ?? [];
|
||||||
|
list.push(
|
||||||
|
`${JSON.stringify(declaration.displayName)}: { id: ${JSON.stringify(query.id)}, plan: ${JSON.stringify(executionPlan)}, root: ${JSON.stringify(declaration.root.interface)}, rootKind: ${JSON.stringify(declaration.root.kind)} } as const satisfies ReplicaProjection<${JSON.stringify(declaration.root.interface)}, ${JSON.stringify(declaration.root.kind)}>`,
|
||||||
|
);
|
||||||
|
queries.set(pkg.displayName, list);
|
||||||
|
}
|
||||||
|
const groups = (entries: Map<string, string[]>) =>
|
||||||
|
[...entries].map(([name, values]) => `${JSON.stringify(name)}: {${values.join(",\n")}}`).join(",\n");
|
||||||
|
return `// Generated application descriptors. Regenerate; do not edit.\nimport { defineOperationDescriptor, decodeArgument, workspaceId, type Json, type PreparedChange, type ManagedReference, type ReplicaProjection, type ServerProjection, type LocalQueryDescriptor } from ${JSON.stringify(options.runtimeModule ?? "@quixos/camino-replica-core")};\nexport const planDigest = ${JSON.stringify(executionPlan)};\nexport const operations = {${groups(atoms)}} as const;\nexport const queries = {${groups(queries)}} as const;\nexport const views = {${groups(views)}} as const;\nexport const serverProjections = {${groups(serverProjections)}} as const;\nexport const results = {${groups(results)}} as const;\nexport const unavailable = ${JSON.stringify(unavailable)} as const;\n`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { portableReflectionType } from "../execution-world/reflection.js";
|
||||||
|
import type { BindingContractSchema } from "./index.js";
|
||||||
|
import type {
|
||||||
|
ValueType,
|
||||||
|
ObjectExpectation,
|
||||||
|
PackageExport,
|
||||||
|
DependencyPort,
|
||||||
|
ExecutionContract,
|
||||||
|
} from "../capability-model/types.js";
|
||||||
|
|
||||||
|
export class PortableBindingError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly path: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(`${path}: ${message}`);
|
||||||
|
this.name = "PortableBindingError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const q = JSON.stringify;
|
||||||
|
const unit: ValueType = { kind: "builtin", name: "unit" };
|
||||||
|
const string: ValueType = { kind: "scalar", name: "string" };
|
||||||
|
const bool: ValueType = { kind: "scalar", name: "bool" };
|
||||||
|
const isUnit = (t: ValueType) => t.kind === "builtin" && t.name === "unit";
|
||||||
|
const expectation = (e: ObjectExpectation) => ({
|
||||||
|
kind: e.kind,
|
||||||
|
id: e.kind === "atom" ? e.atomId : e.interfaceRevisionId,
|
||||||
|
});
|
||||||
|
/** The protocol IR and replica wire schema intentionally share structure, not reference identity. */
|
||||||
|
export function portableValueSchema(type: ValueType): unknown {
|
||||||
|
switch (type.kind) {
|
||||||
|
case "variant":
|
||||||
|
return {
|
||||||
|
kind: "variant",
|
||||||
|
id: type.id,
|
||||||
|
cases: Object.fromEntries(
|
||||||
|
Object.entries(type.cases).map(([label, entry]) => [
|
||||||
|
label,
|
||||||
|
{ id: entry.id, value: portableValueSchema(entry.value) },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case "change":
|
||||||
|
return {
|
||||||
|
kind: "record",
|
||||||
|
fields: {
|
||||||
|
codec: { kind: "scalar", name: "string" },
|
||||||
|
profile: { kind: "scalar", name: "string" },
|
||||||
|
generation: { kind: "scalar", name: "string" },
|
||||||
|
changes: { kind: "list", value: { kind: "scalar", name: "bytes" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "scalar":
|
||||||
|
return type;
|
||||||
|
case "builtin":
|
||||||
|
if (type.name === "unit") return type;
|
||||||
|
break;
|
||||||
|
case "object-ref":
|
||||||
|
return { kind: type.kind, expectation: expectation(type.expectation) };
|
||||||
|
case "optional":
|
||||||
|
case "list":
|
||||||
|
return { kind: type.kind, value: portableValueSchema(type.value) };
|
||||||
|
case "record":
|
||||||
|
return {
|
||||||
|
kind: type.kind,
|
||||||
|
fields: Object.fromEntries(
|
||||||
|
Object.entries(type.fields).map(([key, value]) => [key, portableValueSchema(value)]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new PortableBindingError(
|
||||||
|
type.kind,
|
||||||
|
"Unsupported portable value; use a supported record/reference contract (native edits and generic adapters have separate tasks)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function tsType(type: ValueType): string {
|
||||||
|
portableValueSchema(type);
|
||||||
|
switch (type.kind) {
|
||||||
|
case "variant":
|
||||||
|
return `(${Object.entries(type.cases)
|
||||||
|
.map(([label, entry]) => `{ readonly kind: ${q(label)}; readonly value: ${tsType(entry.value)} }`)
|
||||||
|
.join(" | ")})`;
|
||||||
|
case "change":
|
||||||
|
return `PreparedChange<${tsType(type.value)}>`;
|
||||||
|
case "scalar":
|
||||||
|
return type.name === "bool"
|
||||||
|
? "boolean"
|
||||||
|
: type.name === "string"
|
||||||
|
? "string"
|
||||||
|
: type.name === "bytes"
|
||||||
|
? "Uint8Array"
|
||||||
|
: ["int64", "uint64"].includes(type.name)
|
||||||
|
? "bigint"
|
||||||
|
: "number";
|
||||||
|
case "builtin":
|
||||||
|
return "null";
|
||||||
|
case "object-ref": {
|
||||||
|
const e = expectation(type.expectation);
|
||||||
|
return `ManagedReference<${q(e.kind)}, ${q(e.id)}>`;
|
||||||
|
}
|
||||||
|
case "optional":
|
||||||
|
return `(${tsType(type.value)} | null)`;
|
||||||
|
case "list":
|
||||||
|
return `ReadonlyArray<${tsType(type.value)}>`;
|
||||||
|
case "record":
|
||||||
|
return `{ ${Object.entries(type.fields)
|
||||||
|
.map(([key, value]) => `readonly ${q(key)}: ${tsType(value)}`)
|
||||||
|
.join("; ")} }`;
|
||||||
|
default:
|
||||||
|
throw new PortableBindingError(type.kind, "Unsupported type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface Method {
|
||||||
|
name: string;
|
||||||
|
portId: string;
|
||||||
|
portName: string;
|
||||||
|
action: { kind: string; primitive?: string; operationId?: string; selector?: string };
|
||||||
|
input: ValueType;
|
||||||
|
output: ValueType;
|
||||||
|
keyed: boolean;
|
||||||
|
explicitInput?: boolean;
|
||||||
|
}
|
||||||
|
function methods(
|
||||||
|
schema: BindingContractSchema,
|
||||||
|
entry: PackageExport,
|
||||||
|
port: DependencyPort,
|
||||||
|
target: string,
|
||||||
|
remote = false,
|
||||||
|
): Method[] {
|
||||||
|
const req = port.requirement;
|
||||||
|
const path = `${entry.displayName}.ports.${port.displayName}`;
|
||||||
|
const method = (
|
||||||
|
name: string,
|
||||||
|
action: Method["action"],
|
||||||
|
input: ValueType,
|
||||||
|
output: ValueType,
|
||||||
|
keyed = false,
|
||||||
|
): Method => ({ name, portId: port.id, portName: port.displayName, action, input, output, keyed });
|
||||||
|
const writable = () => {
|
||||||
|
if (remote)
|
||||||
|
throw new PortableBindingError(path, "Remote writes and creation require a named transaction capability");
|
||||||
|
if (entry.execution?.kind !== "transaction")
|
||||||
|
throw new PortableBindingError(path, "Snapshot context cannot expose mutation ports");
|
||||||
|
};
|
||||||
|
switch (req.kind) {
|
||||||
|
case "reflection":
|
||||||
|
if (remote)
|
||||||
|
throw new PortableBindingError(
|
||||||
|
path,
|
||||||
|
"Use an ordinary portable snapshot capability to read installation metadata",
|
||||||
|
);
|
||||||
|
return req.selectors.map((selector) =>
|
||||||
|
method(selector, { kind: "reflection", selector }, unit, portableReflectionType(selector)),
|
||||||
|
);
|
||||||
|
case "creation":
|
||||||
|
writable();
|
||||||
|
return [
|
||||||
|
method(
|
||||||
|
"create",
|
||||||
|
{ kind: "creation" },
|
||||||
|
req.inputType,
|
||||||
|
{ kind: "object-ref", expectation: { kind: "atom", atomId: req.atomId } },
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
case "state":
|
||||||
|
return req.primitives.map((primitive) => {
|
||||||
|
if (primitive === "read") return method("get", { kind: "state", primitive }, unit, req.valueType);
|
||||||
|
if (primitive === "write") {
|
||||||
|
writable();
|
||||||
|
return method("set", { kind: "state", primitive }, req.valueType, unit);
|
||||||
|
}
|
||||||
|
if (primitive === "edit") {
|
||||||
|
writable();
|
||||||
|
return method("edit", { kind: "state", primitive }, { kind: "change", value: req.valueType }, unit);
|
||||||
|
}
|
||||||
|
throw new PortableBindingError(path, `State ${primitive} is not supported by this portable adapter yet`);
|
||||||
|
});
|
||||||
|
case "edge": {
|
||||||
|
const ref: ValueType = { kind: "object-ref", expectation: req.target };
|
||||||
|
const mapKey: ValueType | undefined = req.keyType
|
||||||
|
? { kind: "scalar", name: req.keyType === "boolean" ? "bool" : req.keyType }
|
||||||
|
: undefined;
|
||||||
|
const item: ValueType = { kind: "record", fields: { entry: string, target: ref, ...(mapKey ? { mapKey } : {}) } };
|
||||||
|
const connectInput: ValueType = mapKey ? { kind: "record", fields: { mapKey, target: ref } } : ref;
|
||||||
|
return req.primitives.map((primitive) => {
|
||||||
|
if (primitive === "resolve")
|
||||||
|
return method("resolve", { kind: "edge", primitive }, unit, { kind: "list", value: item });
|
||||||
|
writable();
|
||||||
|
if (primitive === "connect") return method("connect", { kind: "edge", primitive }, connectInput, string, true);
|
||||||
|
if (primitive === "disconnect") return method("disconnect", { kind: "edge", primitive }, string, bool);
|
||||||
|
throw new PortableBindingError(path, `Edge ${primitive} is not supported by this portable adapter yet`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case "interface": {
|
||||||
|
const iface = schema.interfaces.find((i) => i.revisionId === req.interfaceRevisionId);
|
||||||
|
if (!iface) throw new PortableBindingError(path, `Missing interface ${req.interfaceRevisionId}`);
|
||||||
|
const operations = iface.members.flatMap((member) => member.operations.map((op) => ({ member, op })));
|
||||||
|
if (req.operations?.some((id) => !operations.some(({ op }) => op.id === id)))
|
||||||
|
throw new PortableBindingError(path, "Unknown selected interface operation");
|
||||||
|
return operations
|
||||||
|
.filter(({ op }) => !req.operations || req.operations.includes(op.id))
|
||||||
|
.map(({ member, op }) => {
|
||||||
|
const execution: ExecutionContract = op.execution ?? { kind: "remote" };
|
||||||
|
if (
|
||||||
|
(execution.kind === "remote" && !remote) ||
|
||||||
|
op.mode !== "call" ||
|
||||||
|
(op.scope === "class" && (remote || req.receiver === "input"))
|
||||||
|
)
|
||||||
|
throw new PortableBindingError(
|
||||||
|
`${path}.${member.displayName}.${op.displayName}`,
|
||||||
|
`Selected operation is not an object call available in portable target ${target}; select a portable subset or move this handler to remote execution`,
|
||||||
|
);
|
||||||
|
if (execution.kind === "transaction" && !remote) writable();
|
||||||
|
if (req.receiver === "input" && remote)
|
||||||
|
throw new PortableBindingError(path, "Reference-selected dependencies require portable execution");
|
||||||
|
return {
|
||||||
|
...method(
|
||||||
|
`${member.displayName}.${op.displayName}`,
|
||||||
|
{ kind: "interface", operationId: op.id },
|
||||||
|
req.receiver === "input"
|
||||||
|
? {
|
||||||
|
kind: "record",
|
||||||
|
fields: {
|
||||||
|
receiver: {
|
||||||
|
kind: "object-ref",
|
||||||
|
expectation: { kind: "interface", interfaceRevisionId: req.interfaceRevisionId },
|
||||||
|
},
|
||||||
|
input: op.inputType,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: op.inputType,
|
||||||
|
op.outputType,
|
||||||
|
execution.kind === "transaction",
|
||||||
|
),
|
||||||
|
...(op.scope === "class" ? { explicitInput: true } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new PortableBindingError(path, `Unsupported ${req.kind} port in portable bindings`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export interface PortableBindingOptions {
|
||||||
|
target: string;
|
||||||
|
runtimeModule?: string;
|
||||||
|
}
|
||||||
|
/** Generate one target's handlers. The same source signatures are used in browser/server builds. */
|
||||||
|
export function generatePortableTypeScriptBindings(
|
||||||
|
schema: BindingContractSchema,
|
||||||
|
revision: string,
|
||||||
|
options: PortableBindingOptions,
|
||||||
|
): string {
|
||||||
|
return generateBindings(schema, revision, options, false);
|
||||||
|
}
|
||||||
|
export function generateRemoteTypeScriptBindings(
|
||||||
|
schema: BindingContractSchema,
|
||||||
|
revision: string,
|
||||||
|
options: { runtimeModule?: string } = {},
|
||||||
|
): string {
|
||||||
|
return generateBindings(schema, revision, { ...options, target: "server" }, true);
|
||||||
|
}
|
||||||
|
function generateBindings(
|
||||||
|
schema: BindingContractSchema,
|
||||||
|
revision: string,
|
||||||
|
options: PortableBindingOptions,
|
||||||
|
remote: boolean,
|
||||||
|
): string {
|
||||||
|
const mode = remote ? "Remote" : "Portable";
|
||||||
|
const pkg = schema.packages.find((p) => p.revisionId === revision);
|
||||||
|
if (!pkg) throw new PortableBindingError(revision, "Missing package");
|
||||||
|
const entries = pkg.exports.filter((e) =>
|
||||||
|
remote
|
||||||
|
? !e.execution || e.execution.kind === "remote"
|
||||||
|
: e.execution && e.execution.kind !== "remote" && e.execution.targets.includes(options.target),
|
||||||
|
);
|
||||||
|
const names = new Set<string>();
|
||||||
|
const contexts: string[] = [],
|
||||||
|
implementations: string[] = [],
|
||||||
|
adapters: string[] = [],
|
||||||
|
descriptors: unknown[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const effect = entry.execution?.kind ?? "remote";
|
||||||
|
// The checked binding schema already contains concrete generic applications.
|
||||||
|
// Each has its own identity, value codecs and specialized dependency ports.
|
||||||
|
if (entry.kind === "constructor")
|
||||||
|
throw new PortableBindingError(entry.id, "Legacy constructors are not supported; declare an ordinary factory");
|
||||||
|
if (names.has(entry.displayName)) throw new PortableBindingError(entry.id, "Duplicate handler name");
|
||||||
|
names.add(entry.displayName);
|
||||||
|
const portNames = new Set<string>();
|
||||||
|
const all: Method[] = [],
|
||||||
|
facets: string[] = [];
|
||||||
|
for (const port of entry.dependencyPorts) {
|
||||||
|
if (portNames.has(port.displayName))
|
||||||
|
throw new PortableBindingError(entry.id, `Duplicate port name ${port.displayName}`);
|
||||||
|
portNames.add(port.displayName);
|
||||||
|
const selected = methods(schema, entry, port, options.target, remote);
|
||||||
|
if (new Set(selected.map((m) => m.name)).size !== selected.length)
|
||||||
|
throw new PortableBindingError(port.id, "Duplicate method name");
|
||||||
|
all.push(...selected);
|
||||||
|
facets.push(
|
||||||
|
`readonly ${q(port.displayName)}: { ${selected.map((m) => `readonly ${q(m.name)}: (${[...(isUnit(m.input) && !m.explicitInput ? [] : [`input: ${tsType(m.input)}`]), ...(m.keyed ? [remote ? "options: ServerMutationOptions" : "options: MutationKey"] : [])].join(", ")}) => Promise<${tsType(m.output)}>`).join("; ")} }`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const receiver =
|
||||||
|
entry.kind === "function"
|
||||||
|
? null
|
||||||
|
: entry.receiverRequirement.kind === "exact-atom"
|
||||||
|
? { kind: "atom", id: entry.receiverRequirement.atomId }
|
||||||
|
: "object";
|
||||||
|
const receiverType =
|
||||||
|
receiver === null
|
||||||
|
? "null"
|
||||||
|
: receiver === "object"
|
||||||
|
? "ExactReference"
|
||||||
|
: `ManagedReference<"atom", ${q(receiver.id)}>`;
|
||||||
|
contexts.push(
|
||||||
|
`readonly ${q(entry.displayName)}: { readonly effect: ${q(effect)}; ${remote ? "readonly signal: AbortSignal;" : ""} readonly input: ${tsType(entry.inputType)}; readonly receiver: ${receiverType}; readonly ports: { ${facets.join("; ")} } }`,
|
||||||
|
);
|
||||||
|
const output = tsType(entry.outputType);
|
||||||
|
implementations.push(
|
||||||
|
`readonly ${q(entry.displayName)}: (context: Contexts[${q(entry.displayName)}]) => ${output} | Promise<${output}>`,
|
||||||
|
);
|
||||||
|
adapters.push(
|
||||||
|
`${q(entry.id)}: context => installed[${q(entry.displayName)}](context as Contexts[${q(entry.displayName)}])`,
|
||||||
|
);
|
||||||
|
descriptors.push({
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.displayName,
|
||||||
|
effect,
|
||||||
|
receiver,
|
||||||
|
input: portableValueSchema(entry.inputType),
|
||||||
|
output: portableValueSchema(entry.outputType),
|
||||||
|
methods: all.map((m) => ({ ...m, input: portableValueSchema(m.input), output: portableValueSchema(m.output) })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const groups = new Map<string, typeof entries>();
|
||||||
|
for (const template of pkg.genericExports ?? []) {
|
||||||
|
if (template.execution?.kind !== "remote" && template.execution?.targets.includes(options.target))
|
||||||
|
groups.set(template.displayName, []);
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.application) continue;
|
||||||
|
const template = pkg.genericExports?.find((item) => item.id === entry.application!.exportId);
|
||||||
|
if (!template) throw new PortableBindingError(entry.id, "Missing generic export template");
|
||||||
|
groups.set(template.displayName, [...(groups.get(template.displayName) ?? []), entry]);
|
||||||
|
}
|
||||||
|
const generic = [...groups].map(([name, applications]) => {
|
||||||
|
if (!applications.length) return `${q(name)}: (_handler: unknown) => ({})`;
|
||||||
|
contexts.push(
|
||||||
|
`readonly ${q(name)}: ${applications.map((entry) => `Contexts[${q(entry.displayName)}]`).join(" | ")}`,
|
||||||
|
);
|
||||||
|
const type = applications
|
||||||
|
.map(
|
||||||
|
(entry) =>
|
||||||
|
`((context: Contexts[${q(entry.displayName)}]) => ${tsType(entry.outputType)} | Promise<${tsType(entry.outputType)}>)`,
|
||||||
|
)
|
||||||
|
.join(" & ");
|
||||||
|
const handlers = applications
|
||||||
|
.map((entry) => `${q(entry.displayName)}: (context: Contexts[${q(entry.displayName)}]) => handler(context)`)
|
||||||
|
.join(",");
|
||||||
|
return `${q(name)}: (handler: ${type}) => ({${handlers}})`;
|
||||||
|
});
|
||||||
|
return `// Generated ${remote ? "remote" : "portable"} bindings. Regenerate from the checked capability schema; do not edit.\nimport { define${mode}Registry, type ${mode}Export, type PreparedChange, type ManagedReference, type ExactReference, type ${remote ? "ServerMutationOptions" : "MutationKey"} } from ${q(options.runtimeModule ?? "@quixos/camino-replica-core")};\nexport const target = ${q(options.target)};\nexport type Contexts = { ${contexts.join(";\n")} };\nexport type ${mode}Implementation = { ${implementations.join(";\n")} };\nexport const declarations = ${JSON.stringify(descriptors, null, 2)} as const satisfies readonly ${mode}Export[];\nexport const generic = { ${generic.join(",\n")} };\nexport function createRegistry(handlers: ${mode}Implementation) { const installed = { ...handlers }; return define${mode}Registry(declarations, { ${adapters.join(",\n")} }); }\n`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type { InterfaceRevision, PackageRevision, ValueType } from "../capability-model/types.js";
|
||||||
|
import { defaultQueryBudgets, type QueryDeclaration } from "../query/types.js";
|
||||||
|
|
||||||
|
/** React contract tooling generates ordinary queries before normal checking.
|
||||||
|
* Neither the query language nor its execution planner knows presentation IDs. */
|
||||||
|
export function presentationQueries(pkg: PackageRevision, interfaces: readonly InterfaceRevision[]) {
|
||||||
|
const documents = new Map<string, string>();
|
||||||
|
const generated: QueryDeclaration[] = [];
|
||||||
|
const contracts = new Map(interfaces.map((contract) => [contract.revisionId, contract]));
|
||||||
|
const navigation = new Set<string>();
|
||||||
|
for (const module of pkg.modules ?? []) {
|
||||||
|
const contract = contracts.get(module.interfaceRevisionId);
|
||||||
|
const member = contract?.members.find((member) => member.id === module.memberId);
|
||||||
|
if (member?.kind !== "module" || member.contract !== "org.quixos.react.component/2") continue;
|
||||||
|
const argument = contract?.application?.arguments[1];
|
||||||
|
if (argument?.kind !== "object" || argument.target.kind !== "interface")
|
||||||
|
throw Error("Stateful React presentations require a closed navigation interface");
|
||||||
|
navigation.add(argument.target.interfaceRevisionId);
|
||||||
|
}
|
||||||
|
const valueSelection = (type: ValueType): string => {
|
||||||
|
if (type.kind === "optional" || type.kind === "list") return valueSelection(type.value);
|
||||||
|
return type.kind === "record"
|
||||||
|
? `{${Object.entries(type.fields)
|
||||||
|
.map(([name, type]) => `${name} ${valueSelection(type)}`)
|
||||||
|
.join(" ")}}`
|
||||||
|
: "";
|
||||||
|
};
|
||||||
|
for (const id of [...navigation].sort()) {
|
||||||
|
const contract = contracts.get(id as InterfaceRevision["revisionId"]);
|
||||||
|
if (!contract || contract.template) throw Error(`Missing closed presentation navigation ${id}`);
|
||||||
|
const fields = contract.members.flatMap((member) => {
|
||||||
|
if ((member.kind !== "value" && member.kind !== "relationship") || !member.queryRead) return [];
|
||||||
|
if (member.kind === "value") return [`${member.displayName} ${valueSelection(member.valueType)}`];
|
||||||
|
if (member.target.kind !== "interface")
|
||||||
|
throw Error(`Navigation relationship ${member.displayName} must declare an interface view`);
|
||||||
|
return [
|
||||||
|
member.cardinality === "many" || member.cardinality === "many-unique"
|
||||||
|
? `${member.displayName}(all:512) {entries {node {_camino {ref}}}}`
|
||||||
|
: `${member.displayName} {_camino {ref}}`,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const stem = `PresentationNavigation${createHash("sha256").update(id).digest("hex").slice(0, 12)}`;
|
||||||
|
// Retain full state for operations and hidden occurrences, independently
|
||||||
|
// from the identity projection used to prepare a rendering occurrence.
|
||||||
|
for (const identity of [false, true]) {
|
||||||
|
const prefix = `presentation:navigation:${id}:${identity ? "identity-" : ""}`;
|
||||||
|
const inputs = prefix + "inputs";
|
||||||
|
for (const local of [false, true]) {
|
||||||
|
const name = `${stem}${identity ? "Identity" : ""}${local ? "View" : "Inputs"}`;
|
||||||
|
const document = `generated/${name}.graphql`;
|
||||||
|
documents.set(
|
||||||
|
document,
|
||||||
|
`query ${name}($presentationMount: QxRef_PresentationMount!) {
|
||||||
|
root { ${local ? "availableObjects" : "objects"}(all:512, where:{_camino:{as:{PresentationState:{presentationMount:{eq:$presentationMount}}}}}) {
|
||||||
|
entries { node { ${identity ? "" : fields.join("\n")}
|
||||||
|
... on PresentationState {
|
||||||
|
_presentationOccurrence: occurrence
|
||||||
|
_presentationSource: source {_camino {ref}}
|
||||||
|
_presentationParent: parent {_camino {ref}}
|
||||||
|
_presentationMount: presentationMount {_camino {ref}}
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
} }
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
generated.push({
|
||||||
|
id: local ? prefix + "view" : inputs,
|
||||||
|
displayName: name,
|
||||||
|
root: { kind: "workspace-interface", interface: contract.revisionId },
|
||||||
|
document,
|
||||||
|
operation: name,
|
||||||
|
fragments: [],
|
||||||
|
views: [],
|
||||||
|
allowances: [],
|
||||||
|
budgets: { ...defaultQueryBudgets, rows: 5000, candidates: 5000, resultBytes: 8388608 },
|
||||||
|
watch: local,
|
||||||
|
placement: local ? { kind: "local", sourceQueryId: inputs } : { kind: "replica" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const query of generated)
|
||||||
|
if (pkg.queries?.some((existing) => existing.id === query.id || existing.displayName === query.displayName))
|
||||||
|
throw Error(`Presentation query conflicts with an authored query: ${query.id}`);
|
||||||
|
pkg.queries = [...(pkg.queries ?? []), ...generated];
|
||||||
|
return documents;
|
||||||
|
}
|
||||||
@@ -5,14 +5,21 @@ declare module "@quixos/web-studio-react-runtime" {
|
|||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
export function useComponentOverlayContainer(): HTMLElement;
|
export function useComponentOverlayContainer(): HTMLElement;
|
||||||
export function useComponentStyleRoot(): ShadowRoot;
|
export function useComponentStyleRoot(): ShadowRoot;
|
||||||
|
/** Aggregate transport counters only; contains no object identities or values. */
|
||||||
|
export function getFieldTransportMetrics(): Partial<Record<"get" | "watch" | "register" | "crdt" | "setter", Readonly<{started: number; active: number; completed: number; failed: number; totalMs: number}>>>;
|
||||||
export type ObjectRef<AtomId extends string> = string & {
|
export type ObjectRef<AtomId extends string> = string & {
|
||||||
readonly $quixosAtom: AtomId;
|
readonly $quixosAtom: AtomId;
|
||||||
};
|
};
|
||||||
export type FieldCapability = {objectId: string; interfaceRevisionId: string; getOperationId: string; watchOperationId?: string; setOperationId?: string; inputKind?: "fields" | "value"};
|
export type ReadableField<T> = {readonly value?: T; readonly $field: "query" | "independent"};
|
||||||
export type ReadableField<T> = {readonly value?: T; readonly capability: FieldCapability};
|
export type QueryField<T> = ReadableField<T> & {readonly $field: "query"};
|
||||||
|
export type IndependentField<T> = ReadableField<T> & {readonly $field: "independent"};
|
||||||
/** set(T) uses the resolved capability: native CRDT fields send incremental edits;
|
/** set(T) uses the resolved capability: native CRDT fields send incremental edits;
|
||||||
* custom/manual setters receive semantic values. Storage provenance grants no authority. */
|
* custom/manual setters receive semantic values. Storage provenance grants no authority. */
|
||||||
export type WritableField<T> = ReadableField<T> & {readonly $writeType?: (value: T) => T; readonly writable: true; readonly capability: FieldCapability & {setOperationId: string}};
|
export type WritableField<T> = ReadableField<T> & {readonly $writeType?: (value: T) => T; readonly writable: true};
|
||||||
|
export type WritableQueryField<T> = WritableField<T> & QueryField<T>;
|
||||||
|
export type WritableIndependentField<T> = WritableField<T> & IndependentField<T>;
|
||||||
|
export function detachField<T>(field: WritableQueryField<T>): WritableIndependentField<T>;
|
||||||
|
export function detachField<T>(field: QueryField<T>): IndependentField<T>;
|
||||||
export type LiveFieldProp<T> = ReadableField<T>;
|
export type LiveFieldProp<T> = ReadableField<T>;
|
||||||
export type InterfaceReference<I extends string, Fields> = {readonly $quixosRef: string; readonly interfaceRevisionId: I; readonly fields: Fields};
|
export type InterfaceReference<I extends string, Fields> = {readonly $quixosRef: string; readonly interfaceRevisionId: I; readonly fields: Fields};
|
||||||
export type ReactInterfaceContract<View> = {readonly interfaceRevisionId: string; readonly $viewType?: (view: View) => View};
|
export type ReactInterfaceContract<View> = {readonly interfaceRevisionId: string; readonly $viewType?: (view: View) => View};
|
||||||
@@ -20,6 +27,19 @@ declare module "@quixos/web-studio-react-runtime" {
|
|||||||
export function tryConform<View>(object: string | {readonly $quixosRef: string}, contract: ReactInterfaceContract<View>, options?: {signal?: AbortSignal}): Promise<View | undefined>;
|
export function tryConform<View>(object: string | {readonly $quixosRef: string}, contract: ReactInterfaceContract<View>, options?: {signal?: AbortSignal}): Promise<View | undefined>;
|
||||||
export type ConformanceResult<View> = {status: "loading"} | {status: "absent"} | {status: "available"; view: View} | {status: "error"; error: Error};
|
export type ConformanceResult<View> = {status: "loading"} | {status: "absent"} | {status: "available"; view: View} | {status: "error"; error: Error};
|
||||||
export function useTryConform<View>(object: string | {readonly $quixosRef: string}, contract: ReactInterfaceContract<View>): ConformanceResult<View>;
|
export function useTryConform<View>(object: string | {readonly $quixosRef: string}, contract: ReactInterfaceContract<View>): ConformanceResult<View>;
|
||||||
|
export type QueryValueType = {kind: "builtin" | "scalar"; name: string} | {kind: "record"; fields: Record<string, QueryValueType>} | {kind: "optional" | "list"; value: QueryValueType} | {kind: "object-ref"; expectation: {kind: "atom"; atomId: string} | {kind: "interface"; interfaceRevisionId: string}};
|
||||||
|
export type QueryReference<Contract extends string = string> = {readonly $quixosRef: string; readonly queryContract: Contract};
|
||||||
|
export type QueryLiveProjection = {path: readonly string[]; selectionId: string; interfaceRevisionId: string; getOperationId: string; setOperationId?: string; watchOperationId?: string; inputKind: "fields" | "value"; valueType: QueryValueType; conditions: {include: boolean; defaultValue?: boolean; value: {kind: "variable"; name: string} | {kind: "literal"; value: unknown}}[]};
|
||||||
|
export interface QueryDescriptor<Variables, Result, Kind extends "object" | "workspace-interface" = "object" | "workspace-interface"> {readonly id: string; readonly definitionDigest: string; readonly root: {readonly kind: Kind; readonly interface: string}; readonly variables: QueryValueType; readonly output: QueryValueType; readonly watch: boolean; readonly liveFields?: readonly QueryLiveProjection[]; readonly $types?: (variables: Variables, result: Result) => [Variables, Result]}
|
||||||
|
export type QueryPartial<T> = T extends {readonly $quixosRef: string} | ReadableField<unknown> ? T : T extends readonly (infer Item)[] ? QueryPartial<Item>[] : T extends object ? {[Key in keyof T]?: QueryPartial<T[Key]>} : T;
|
||||||
|
export type QueryFieldState = {path: readonly (string | number)[]; status: "pending" | "error"; error?: string};
|
||||||
|
export type QueryState<T> = {refreshing: boolean; fields: QueryFieldState[]; runId?: string; sequence?: bigint; consistency?: string} & ({status: "loading"; data?: undefined; error?: undefined} | {status: "ready"; data: T; error?: undefined} | {status: "partial"; data: QueryPartial<T>; error?: undefined} | {status: "error"; data?: T | QueryPartial<T>; error: Error});
|
||||||
|
export type WorkspaceQueryScope = {readonly $workspaceQueryScope: true};
|
||||||
|
export function getWorkspaceQueryScope<Variables, Result>(descriptor: QueryDescriptor<Variables, Result, "workspace-interface">): Promise<WorkspaceQueryScope>;
|
||||||
|
export function useQuery<Variables, Result>(descriptor: QueryDescriptor<Variables, Result, "object">, options: {root: string | {readonly $quixosRef: string}; variables: Variables}): QueryState<Result> & {refresh(): void};
|
||||||
|
export function useQuery<Variables, Result>(descriptor: QueryDescriptor<Variables, Result, "workspace-interface">, options: {scope: WorkspaceQueryScope; variables: Variables}): QueryState<Result> & {refresh(): void};
|
||||||
|
export interface QueryConnection<Row> {entries: {key: string; node: Row; cursor?: string | null}[]; pageInfo: {hasNextPage: boolean; endCursor?: string | null}}
|
||||||
|
export function CollectionView<Result, Row>(props: {result: QueryState<Result> & {refresh(): void}; connection(data: QueryPartial<Result>): QueryPartial<QueryConnection<Row>> | undefined; renderItem(row: QueryPartial<Row>, entryKey: string): React.ReactNode; onCreate?: () => void; onLoadMore?: (cursor: string | null) => void; onReset?: () => void; loadingMore?: boolean; empty?: React.ReactNode; prefetch?: boolean}): React.ReactElement;
|
||||||
export type ReactComponentHostProps<Action> = {
|
export type ReactComponentHostProps<Action> = {
|
||||||
onAction?: (action: Action) => void;
|
onAction?: (action: Action) => void;
|
||||||
fallback?: React.ReactNode;
|
fallback?: React.ReactNode;
|
||||||
@@ -46,8 +66,13 @@ declare module "@quixos/web-studio-react-runtime" {
|
|||||||
options?: {clientMutationId?: string; signal?: AbortSignal},
|
options?: {clientMutationId?: string; signal?: AbortSignal},
|
||||||
) => Promise<Result>;
|
) => Promise<Result>;
|
||||||
export const h: typeof React.createElement;
|
export const h: typeof React.createElement;
|
||||||
export function useLiveField<T>(field: ReadableField<T>, options: {write: (value: T) => Promise<void>}): readonly [T, (value: T) => Promise<void>];
|
export type FieldReadState = "connecting" | "live" | "snapshot" | "inactive" | "not-selected" | "error" | "obsolete";
|
||||||
export function useLiveField<T>(field: WritableField<T>): readonly [T, (value: T) => Promise<void>];
|
export type LiveFieldState<T> = {readState: FieldReadState; refreshing: boolean; stale: boolean; pendingWrites: number; writeError?: Error; refresh(): Promise<void>; discard(): void} & ({status: "loading"; value?: undefined; error?: undefined} | {status: "ready"; value: T; error?: Error} | {status: "error"; value?: undefined; error: Error});
|
||||||
export function useLiveField<T>(field: ReadableField<T>): readonly [T];
|
export type WritableLiveFieldState<T> = LiveFieldState<T> & {set(value: T): Promise<void>; retry(): Promise<void>};
|
||||||
|
export function useLiveField<T>(field: ReadableField<T>, options: {id: string; write: (value: T) => Promise<void>}): WritableLiveFieldState<T>;
|
||||||
|
export function useLiveField<T>(field: WritableField<T>): WritableLiveFieldState<T>;
|
||||||
|
export function useLiveField<T>(field: ReadableField<T>): LiveFieldState<T>;
|
||||||
|
export function useSuspenseLiveField<T>(field: WritableField<T>): WritableLiveFieldState<T> & {status: "ready"; value: T};
|
||||||
|
export function useSuspenseLiveField<T>(field: ReadableField<T>): LiveFieldState<T> & {status: "ready"; value: T};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
+61
-2
@@ -15,6 +15,8 @@ export function generateReactBindings(
|
|||||||
const declarations: string[] = [];
|
const declarations: string[] = [];
|
||||||
const type = (value: ValueType): string => {
|
const type = (value: ValueType): string => {
|
||||||
switch (value.kind) {
|
switch (value.kind) {
|
||||||
|
case "variant":
|
||||||
|
throw new Error("Variants require the Camino portable value runtime; legacy RPC bindings are unsupported");
|
||||||
case "builtin":
|
case "builtin":
|
||||||
if (value.name !== "unit") throw new Error(`Unsupported React props builtin ${value.name}`);
|
if (value.name !== "unit") throw new Error(`Unsupported React props builtin ${value.name}`);
|
||||||
return "null";
|
return "null";
|
||||||
@@ -29,6 +31,8 @@ export function generateReactBindings(
|
|||||||
uint32: "number",
|
uint32: "number",
|
||||||
double: "number",
|
double: "number",
|
||||||
}[value.name];
|
}[value.name];
|
||||||
|
case "change":
|
||||||
|
throw new Error("CRDT change values require the prepared-change codec and runtime (LF-19)");
|
||||||
case "optional":
|
case "optional":
|
||||||
return `(${type(value.value)} | null)`;
|
return `(${type(value.value)} | null)`;
|
||||||
case "list":
|
case "list":
|
||||||
@@ -78,7 +82,31 @@ export function generateReactBindings(
|
|||||||
throw new Error("React component check must name a local module relative to generated bindings");
|
throw new Error("React component check must name a local module relative to generated bindings");
|
||||||
return `type Component${i} = CheckedComponent<${q(component.propsExport)}, typeof import(${q(component.module)})["default"]>;`;
|
return `type Component${i} = CheckedComponent<${q(component.propsExport)}, typeof import(${q(component.module)})["default"]>;`;
|
||||||
});
|
});
|
||||||
const contracts = schema.interfaces.map((iface) => {
|
// Emit optional discovery descriptors only when the browser has all codecs.
|
||||||
|
// Unrelated opaque-message imports must not break an otherwise checked UI.
|
||||||
|
const browserValue = (value: ValueType, seen: Set<string>): boolean => {
|
||||||
|
if (value.kind === "message") return false;
|
||||||
|
if (value.kind === "builtin") return value.name === "unit";
|
||||||
|
if (value.kind === "variant") return false;
|
||||||
|
if (value.kind === "record") return Object.values(value.fields).every((field) => browserValue(field, seen));
|
||||||
|
if (value.kind === "list" || value.kind === "optional") return browserValue(value.value, seen);
|
||||||
|
if (value.kind === "object-ref" && value.expectation.kind === "interface") {
|
||||||
|
const id = value.expectation.interfaceRevisionId;
|
||||||
|
if (seen.has(id)) return true;
|
||||||
|
const contract = schema.interfaces.find((entry) => entry.revisionId === id);
|
||||||
|
return !!contract && browserInterface(contract, new Set([...seen, id]));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const browserInterface = (iface: (typeof schema.interfaces)[number], seen: Set<string>): boolean =>
|
||||||
|
iface.members.every((member) =>
|
||||||
|
member.operations
|
||||||
|
.filter((op) => op.mode === "call" && op.scope !== "class")
|
||||||
|
.every((op) => browserValue(op.inputType, seen) && browserValue(op.outputType, seen)),
|
||||||
|
);
|
||||||
|
const contracts = schema.interfaces
|
||||||
|
.filter((iface) => browserInterface(iface, new Set([iface.revisionId])))
|
||||||
|
.map((iface) => {
|
||||||
const reference = type({
|
const reference = type({
|
||||||
kind: "object-ref",
|
kind: "object-ref",
|
||||||
expectation: { kind: "interface", interfaceRevisionId: iface.revisionId },
|
expectation: { kind: "interface", interfaceRevisionId: iface.revisionId },
|
||||||
@@ -93,8 +121,39 @@ export function generateReactBindings(
|
|||||||
);
|
);
|
||||||
return { iface, view: `${reference} & {call: {${calls.join(";")}}}` };
|
return { iface, view: `${reference} & {call: {${calls.join(";")}}}` };
|
||||||
});
|
});
|
||||||
|
const queryType = (value: ValueType): string => {
|
||||||
|
if (value.kind === "object-ref")
|
||||||
|
return `QueryReference<${q(value.expectation.kind === "atom" ? value.expectation.atomId : value.expectation.interfaceRevisionId)}>`;
|
||||||
|
if (value.kind === "optional") return `(${queryType(value.value)} | null)`;
|
||||||
|
if (value.kind === "list") return `Array<${queryType(value.value)}>`;
|
||||||
|
if (value.kind === "record")
|
||||||
|
return `{${Object.entries(value.fields)
|
||||||
|
.map(([name, field]) => `${q(name)}: ${queryType(field)}`)
|
||||||
|
.join(";")}}`;
|
||||||
|
return type(value);
|
||||||
|
};
|
||||||
|
const queries = pkg.checkedQueries ?? [];
|
||||||
|
const queryCode =
|
||||||
|
`export type QueryVariables = {${queries.map((query) => `${q(query.declaration.displayName)}: ${queryType(query.variables)}`).join(";")}};\n` +
|
||||||
|
`export type QueryResults = {${queries.map((query) => `${q(query.declaration.displayName)}: ${queryType(query.output)}`).join(";")}};\n` +
|
||||||
|
`export const queries: {${queries.map((query) => `${q(query.declaration.displayName)}: QueryDescriptor<QueryVariables[${q(query.declaration.displayName)}], QueryResults[${q(query.declaration.displayName)}], ${q(query.declaration.root.kind)}>`).join(";")}} = ${JSON.stringify(
|
||||||
|
Object.fromEntries(
|
||||||
|
queries.map((query) => [
|
||||||
|
query.declaration.displayName,
|
||||||
|
{
|
||||||
|
id: `${pkg.revisionId}:${query.declaration.id}`,
|
||||||
|
definitionDigest: query.definitionDigest,
|
||||||
|
root: query.declaration.root,
|
||||||
|
variables: query.variables,
|
||||||
|
output: query.output,
|
||||||
|
watch: query.declaration.watch,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)};\n`;
|
||||||
return (
|
return (
|
||||||
`// Generated from checked QX contracts. Do not edit.\nimport {defineReactInterfaceContract} from "@quixos/web-studio-react-runtime";\nimport type {ObjectRef, InterfaceReference, ReadableField, WritableField} from "@quixos/web-studio-react-runtime";\n${declarations.join("\n")}\nexport type ReactResults = {${results.join(";\n")}};\n` +
|
`// Generated from checked QX contracts. Do not edit.\nimport {defineReactInterfaceContract} from "@quixos/web-studio-react-runtime";\nimport type {ObjectRef, InterfaceReference, ReadableField, WritableField, QueryField, WritableQueryField, QueryDescriptor, QueryReference} from "@quixos/web-studio-react-runtime";\n${declarations.join("\n")}\nexport type ReactResults = {${results.join(";\n")}};\n` +
|
||||||
|
queryCode +
|
||||||
`const contractsData = ${JSON.stringify(schema.interfaces)};\n` +
|
`const contractsData = ${JSON.stringify(schema.interfaces)};\n` +
|
||||||
`export const reactContracts = {${contracts.map(({ iface, view }) => `${q(iface.revisionId)}: defineReactInterfaceContract<${view}>(${q(iface.revisionId)}, contractsData)`).join(",\n")}};\n` +
|
`export const reactContracts = {${contracts.map(({ iface, view }) => `${q(iface.revisionId)}: defineReactInterfaceContract<${view}>(${q(iface.revisionId)}, contractsData)`).join(",\n")}};\n` +
|
||||||
`export const reactInterfaces = {${contracts
|
`export const reactInterfaces = {${contracts
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { readFile, realpath } from "node:fs/promises";
|
import { readFile, realpath } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { profile } from "./profile.js";
|
||||||
import { loadQxSources, resolveQxSources } from "./source-loader.js";
|
import { loadQxSources, resolveQxSources } from "./source-loader.js";
|
||||||
|
import { compileQuery, compileRepositoryQuery } from "../query/compile.js";
|
||||||
|
import { queryTreeProjections } from "../query/tree.js";
|
||||||
|
import type { QueryDeclaration } from "../query/types.js";
|
||||||
|
import { compileQueryComposition, type QueryComposition } from "../query/composition.js";
|
||||||
|
import { checkQueryTemplate } from "../query/templates.js";
|
||||||
|
import { readRepositorySource } from "./source-loader.js";
|
||||||
|
import { parseControllerCatalog } from "../capability-model/controllers.js";
|
||||||
|
import { linkQueries } from "../query/link.js";
|
||||||
import {
|
import {
|
||||||
capabilityId,
|
capabilityId,
|
||||||
compileWorkspaceRevision,
|
compileWorkspaceRevision,
|
||||||
@@ -36,13 +45,40 @@ export type CapabilityRepositoryResolver = (
|
|||||||
kind: LockedResource["kind"],
|
kind: LockedResource["kind"],
|
||||||
) => Promise<CapabilityRepositorySnapshot>;
|
) => Promise<CapabilityRepositorySnapshot>;
|
||||||
|
|
||||||
|
/** Already checked outputs of this compiler, composed by the Nix build DAG.
|
||||||
|
* Source capture/check reports never grant this authority. */
|
||||||
|
export type CheckedResourceResolver = (
|
||||||
|
kind: LockedResource["kind"],
|
||||||
|
source: GitSource,
|
||||||
|
lock: QuixosRepositoryLock,
|
||||||
|
dependencies: ReadonlyMap<string, ResolvedCapabilityResource>,
|
||||||
|
) => Promise<CapabilityResource | undefined>;
|
||||||
|
|
||||||
|
/** An explicitly selected build frontend supplies ordinary query sources. The
|
||||||
|
* assembler never chooses a frontend by inspecting an interface/contract ID.
|
||||||
|
* Generated sources go through the same query and imported-fragment checks. */
|
||||||
|
export type PackageQueryGenerator = ((input: {
|
||||||
|
readonly package: Readonly<PackageRevision>;
|
||||||
|
readonly interfaces: readonly InterfaceRevision[];
|
||||||
|
readonly readSource: (name: string) => Promise<string>;
|
||||||
|
}) => Promise<{
|
||||||
|
readonly queries: readonly QueryDeclaration[];
|
||||||
|
readonly documents: ReadonlyMap<string, string>;
|
||||||
|
readonly compositions?: readonly QueryComposition[];
|
||||||
|
readonly artifacts?: Readonly<Record<string, unknown>>;
|
||||||
|
}>) & {
|
||||||
|
/** Optional explicit frontend stage after workspace binding resolution.
|
||||||
|
* Its output still passes normal workspace/query validation. */
|
||||||
|
completeWorkspace?: (workspace: WorkspaceRevision) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ResolvedCapabilityResource = {
|
export type ResolvedCapabilityResource = {
|
||||||
key: string;
|
key: string;
|
||||||
kind: LockedResource["kind"];
|
kind: LockedResource["kind"];
|
||||||
source: GitSource;
|
source: GitSource;
|
||||||
directory: string;
|
directory: string;
|
||||||
lock: QuixosRepositoryLock;
|
lock: QuixosRepositoryLock;
|
||||||
resource: CapabilityResource;
|
resource: CapabilityResource | { kind: "build" };
|
||||||
dependencies: ReadonlyMap<string, ResolvedCapabilityResource>;
|
dependencies: ReadonlyMap<string, ResolvedCapabilityResource>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,13 +87,15 @@ export type CompiledWorkspaceRepository = {
|
|||||||
plan: CompiledWorkspaceRevision;
|
plan: CompiledWorkspaceRevision;
|
||||||
lock: QuixosRepositoryLock;
|
lock: QuixosRepositoryLock;
|
||||||
resources: ResolvedCapabilityResource[];
|
resources: ResolvedCapabilityResource[];
|
||||||
|
capabilityResources: ResolvedCapabilityResource[];
|
||||||
directResources: ReadonlyMap<string, ResolvedCapabilityResource>;
|
directResources: ReadonlyMap<string, ResolvedCapabilityResource>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CompiledCapabilityResourceRepository = {
|
export type CompiledCapabilityResourceRepository = {
|
||||||
resource: CapabilityResource;
|
resource: CapabilityResource | { kind: "build" };
|
||||||
lock: QuixosRepositoryLock;
|
lock: QuixosRepositoryLock;
|
||||||
resources: ResolvedCapabilityResource[];
|
resources: ResolvedCapabilityResource[];
|
||||||
|
capabilityResources: ResolvedCapabilityResource[];
|
||||||
directResources: ReadonlyMap<string, ResolvedCapabilityResource>;
|
directResources: ReadonlyMap<string, ResolvedCapabilityResource>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,7 +112,10 @@ const assertImportsMatchLock = (
|
|||||||
resources: readonly LockedResource[],
|
resources: readonly LockedResource[],
|
||||||
) => {
|
) => {
|
||||||
const authored = [...imports].map(importsKey).sort();
|
const authored = [...imports].map(importsKey).sort();
|
||||||
const locked = [...resources].map(importsKey).sort();
|
const locked = resources
|
||||||
|
.filter((entry) => entry.kind !== "build")
|
||||||
|
.map(importsKey)
|
||||||
|
.sort();
|
||||||
if (authored.length !== locked.length || authored.some((entry, index) => entry !== locked[index])) {
|
if (authored.length !== locked.length || authored.some((entry, index) => entry !== locked[index])) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${label} imports do not match quixos.lock:\n` +
|
`${label} imports do not match quixos.lock:\n` +
|
||||||
@@ -132,13 +173,19 @@ const diagnosticsMessage = (
|
|||||||
})
|
})
|
||||||
.join("\n")}`;
|
.join("\n")}`;
|
||||||
|
|
||||||
const revisionFor = (node: ResolvedCapabilityResource) => node.resource.revision;
|
const revisionFor = (node: ResolvedCapabilityResource) => {
|
||||||
|
if (node.resource.kind === "build") throw Error("Build sources have no capability revision");
|
||||||
|
return node.resource.revision;
|
||||||
|
};
|
||||||
|
|
||||||
const resourceClosure = (roots: readonly ResolvedCapabilityResource[]): ResolvedCapabilityResource[] => {
|
const resourceClosure = (
|
||||||
|
roots: readonly ResolvedCapabilityResource[],
|
||||||
|
capabilityOnly = false,
|
||||||
|
): ResolvedCapabilityResource[] => {
|
||||||
const result: ResolvedCapabilityResource[] = [];
|
const result: ResolvedCapabilityResource[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const visit = (node: ResolvedCapabilityResource) => {
|
const visit = (node: ResolvedCapabilityResource) => {
|
||||||
if (seen.has(node.key)) return;
|
if (seen.has(node.key) || (capabilityOnly && node.kind === "build")) return;
|
||||||
seen.add(node.key);
|
seen.add(node.key);
|
||||||
for (const dependency of node.dependencies.values()) visit(dependency);
|
for (const dependency of node.dependencies.values()) visit(dependency);
|
||||||
result.push(node);
|
result.push(node);
|
||||||
@@ -150,7 +197,12 @@ const resourceClosure = (roots: readonly ResolvedCapabilityResource[]): Resolved
|
|||||||
const environmentFor = (
|
const environmentFor = (
|
||||||
direct: readonly [LockedResource, ResolvedCapabilityResource][],
|
direct: readonly [LockedResource, ResolvedCapabilityResource][],
|
||||||
closure: readonly ResolvedCapabilityResource[],
|
closure: readonly ResolvedCapabilityResource[],
|
||||||
): CapabilityImportEnvironment => ({
|
): CapabilityImportEnvironment => {
|
||||||
|
closure = resourceClosure(
|
||||||
|
direct.map(([, node]) => node),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return {
|
||||||
interfaces: new Map(
|
interfaces: new Map(
|
||||||
direct.flatMap(([locked, node]) =>
|
direct.flatMap(([locked, node]) =>
|
||||||
node.resource.kind === "interface" ? [[locked.binding, node.resource.revision] as const] : [],
|
node.resource.kind === "interface" ? [[locked.binding, node.resource.revision] as const] : [],
|
||||||
@@ -164,16 +216,28 @@ const environmentFor = (
|
|||||||
interfaceClosure: exactRevisions(
|
interfaceClosure: exactRevisions(
|
||||||
closure.flatMap((node) => [
|
closure.flatMap((node) => [
|
||||||
...(node.resource.kind === "interface" ? [node.resource.revision] : []),
|
...(node.resource.kind === "interface" ? [node.resource.revision] : []),
|
||||||
...(node.resource.specializations ?? []),
|
...(node.resource.kind === "build" ? [] : (node.resource.specializations ?? [])),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
packageClosure: exactRevisions(
|
packageClosure: exactRevisions(
|
||||||
closure.flatMap((node) => (node.resource.kind === "package" ? [node.resource.revision] : [])),
|
closure.flatMap((node) => (node.resource.kind === "package" ? [node.resource.revision] : [])),
|
||||||
),
|
),
|
||||||
externalAtoms: exactAtoms(closure.flatMap((node) => node.resource.externalAtoms)),
|
externalAtoms: exactAtoms(
|
||||||
});
|
closure.flatMap((node) => (node.resource.kind === "build" ? [] : node.resource.externalAtoms)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const createResourceGraphResolver = (quixosCommit: string, resolveResource: CapabilityRepositoryResolver) => {
|
const createResourceGraphResolver = (
|
||||||
|
quixosCommit: string,
|
||||||
|
resolveResource: CapabilityRepositoryResolver,
|
||||||
|
generateQueries?: PackageQueryGenerator,
|
||||||
|
checkedResource?: CheckedResourceResolver,
|
||||||
|
) => {
|
||||||
|
const graphStarted = performance.now();
|
||||||
|
let checkedResources = 0,
|
||||||
|
reusedResources = 0,
|
||||||
|
checkedQueries = 0;
|
||||||
const resolved = new Map<string, Promise<ResolvedCapabilityResource>>();
|
const resolved = new Map<string, Promise<ResolvedCapabilityResource>>();
|
||||||
const active: string[] = [];
|
const active: string[] = [];
|
||||||
|
|
||||||
@@ -213,7 +277,44 @@ const createResourceGraphResolver = (quixosCommit: string, resolveResource: Capa
|
|||||||
for (const dependency of lockResult.lock.resources) {
|
for (const dependency of lockResult.lock.resources) {
|
||||||
directPairs.push([dependency, await visit(dependency)]);
|
directPairs.push([dependency, await visit(dependency)]);
|
||||||
}
|
}
|
||||||
|
if (locked.kind === "build") {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
kind: locked.kind,
|
||||||
|
source: locked.source,
|
||||||
|
directory: snapshot.directory,
|
||||||
|
lock: lockResult.lock,
|
||||||
|
resource: { kind: "build" as const },
|
||||||
|
dependencies: new Map(
|
||||||
|
directPairs.map(([dependency, node]) => [bindingKey(dependency.kind, dependency.binding), node]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
const dependencyClosure = resourceClosure(directPairs.map(([, node]) => node));
|
const dependencyClosure = resourceClosure(directPairs.map(([, node]) => node));
|
||||||
|
const dependencies = new Map(
|
||||||
|
directPairs.map(([dependency, node]) => [bindingKey(dependency.kind, dependency.binding), node]),
|
||||||
|
);
|
||||||
|
const checked = await checkedResource?.(locked.kind, locked.source, lockResult.lock, dependencies);
|
||||||
|
if (checked) {
|
||||||
|
if (
|
||||||
|
checked.kind !== locked.kind ||
|
||||||
|
checked.revision.source.repository !== locked.source.repository ||
|
||||||
|
checked.revision.source.commit !== locked.source.commit
|
||||||
|
)
|
||||||
|
throw new Error("Checked resource identity mismatch");
|
||||||
|
assertImportsMatchLock("Checked resource", checked.imports, lockResult.lock.resources);
|
||||||
|
reusedResources++;
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
kind: locked.kind,
|
||||||
|
source: locked.source,
|
||||||
|
directory: snapshot.directory,
|
||||||
|
lock: lockResult.lock,
|
||||||
|
resource: structuredClone(checked),
|
||||||
|
dependencies,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const checkStarted = performance.now();
|
||||||
const environment = environmentFor(directPairs, dependencyClosure);
|
const environment = environmentFor(directPairs, dependencyClosure);
|
||||||
const manifestName = locked.kind === "interface" ? "interface.qx" : "package.qx";
|
const manifestName = locked.kind === "interface" ? "interface.qx" : "package.qx";
|
||||||
const manifestPath = path.join(snapshot.directory, manifestName);
|
const manifestPath = path.join(snapshot.directory, manifestName);
|
||||||
@@ -231,6 +332,98 @@ const createResourceGraphResolver = (quixosCommit: string, resolveResource: Capa
|
|||||||
}
|
}
|
||||||
assertImportsMatchLock(manifestPath, compiled.resource.imports, lockResult.lock.resources);
|
assertImportsMatchLock(manifestPath, compiled.resource.imports, lockResult.lock.resources);
|
||||||
if (compiled.resource.kind === "package") {
|
if (compiled.resource.kind === "package") {
|
||||||
|
const queryInterfaces = [
|
||||||
|
...(environment.interfaceClosure ?? []),
|
||||||
|
...(compiled.resource.specializations ?? []),
|
||||||
|
];
|
||||||
|
const generated = await generateQueries?.({
|
||||||
|
package: structuredClone(compiled.resource.revision),
|
||||||
|
interfaces: structuredClone(queryInterfaces),
|
||||||
|
readSource: (name) => readRepositorySource(snapshot.directory, name),
|
||||||
|
});
|
||||||
|
if (generated) {
|
||||||
|
if (generated.artifacts) compiled.resource.revision.buildArtifacts = structuredClone(generated.artifacts);
|
||||||
|
const queries = [...(compiled.resource.revision.queries ?? [])];
|
||||||
|
for (const query of generated.queries) {
|
||||||
|
if (queries.some((entry) => entry.id === query.id || entry.displayName === query.displayName))
|
||||||
|
throw new Error(`Generated query conflicts with an authored query: ${query.id}`);
|
||||||
|
if (!generated.documents.has(query.document))
|
||||||
|
throw new Error(`Generated query has no source: ${query.document}`);
|
||||||
|
queries.push(structuredClone(query));
|
||||||
|
}
|
||||||
|
compiled.resource.revision.queries = queries;
|
||||||
|
}
|
||||||
|
for (const template of compiled.resource.revision.queryTemplates ?? [])
|
||||||
|
await checkQueryTemplate(template, queryInterfaces, (name) =>
|
||||||
|
readRepositorySource(snapshot.directory, name),
|
||||||
|
);
|
||||||
|
if (compiled.resource.revision.queries?.length) {
|
||||||
|
const pkg = compiled.resource.revision;
|
||||||
|
const checked = new Map<string, import("../query/types.js").CheckedQuery>();
|
||||||
|
const compile = async (
|
||||||
|
id: string,
|
||||||
|
parents: readonly string[] = [],
|
||||||
|
): Promise<import("../query/types.js").CheckedQuery> => {
|
||||||
|
if (parents.includes(id)) throw new Error(`Query fragment cycle: ${[...parents, id].join(" -> ")}`);
|
||||||
|
const previous = checked.get(id);
|
||||||
|
if (previous) return previous;
|
||||||
|
const query = pkg.queries!.find((query) => query.id === id);
|
||||||
|
if (!query) throw new Error(`Unknown fragment ${pkg.revisionId}/${id}`);
|
||||||
|
const resolveFragment = async (packageId: string, fragmentId: string) => {
|
||||||
|
if (packageId === pkg.revisionId) return compile(fragmentId, [...parents, id]);
|
||||||
|
const imported = environment.packageClosure
|
||||||
|
?.find((other) => other.revisionId === packageId)
|
||||||
|
?.checkedQueries?.find((query) => query.declaration.id === fragmentId);
|
||||||
|
if (!imported) throw new Error(`Unchecked imported fragment ${packageId}/${fragmentId}`);
|
||||||
|
return imported;
|
||||||
|
};
|
||||||
|
const result = generated?.documents.has(query.document)
|
||||||
|
? await compileQuery(
|
||||||
|
query,
|
||||||
|
queryInterfaces,
|
||||||
|
async (name) =>
|
||||||
|
generated.documents.get(name) ?? (await readRepositorySource(snapshot.directory, name)),
|
||||||
|
resolveFragment,
|
||||||
|
)
|
||||||
|
: await compileRepositoryQuery(snapshot.directory, query, queryInterfaces, resolveFragment);
|
||||||
|
checked.set(id, result);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
pkg.checkedQueries = [];
|
||||||
|
for (const query of pkg.queries!) pkg.checkedQueries.push(await compile(query.id));
|
||||||
|
const projections = pkg.checkedQueries.flatMap(queryTreeProjections);
|
||||||
|
for (const projection of projections) {
|
||||||
|
if (pkg.queries!.some((query) => query.id === projection.declaration.id))
|
||||||
|
throw new Error(`Generated Query Tree projection conflicts with ${projection.declaration.id}`);
|
||||||
|
pkg.queries!.push(projection.declaration);
|
||||||
|
pkg.checkedQueries.push(projection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (generated?.compositions?.length) {
|
||||||
|
const pkg = compiled.resource.revision;
|
||||||
|
const queries = new Map((pkg.checkedQueries ?? []).map((query) => [query.declaration.id, query]));
|
||||||
|
for (const dependency of environment.packageClosure ?? [])
|
||||||
|
for (const query of dependency.checkedQueries ?? [])
|
||||||
|
queries.set(JSON.stringify([dependency.revisionId, query.declaration.id]), query);
|
||||||
|
const ids = new Set<string>();
|
||||||
|
pkg.queryCompositions = generated.compositions.map((composition) => {
|
||||||
|
if (ids.has(composition.id)) throw new Error(`Duplicate query composition: ${composition.id}`);
|
||||||
|
ids.add(composition.id);
|
||||||
|
return compileQueryComposition(composition, queries);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let controllerText: string | undefined;
|
||||||
|
try {
|
||||||
|
controllerText = await readRepositorySource(snapshot.directory, "quixos.controllers.json");
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
if (controllerText !== undefined)
|
||||||
|
compiled.resource.revision.controllers = parseControllerCatalog(
|
||||||
|
controllerText,
|
||||||
|
queryInterfaces,
|
||||||
|
compiled.resource.revision.checkedQueries,
|
||||||
|
);
|
||||||
let catalogText: string | undefined;
|
let catalogText: string | undefined;
|
||||||
try {
|
try {
|
||||||
catalogText = await readFile(path.join(snapshot.directory, "quixos.migrations.json"), "utf8");
|
catalogText = await readFile(path.join(snapshot.directory, "quixos.migrations.json"), "utf8");
|
||||||
@@ -253,6 +446,15 @@ const createResourceGraphResolver = (quixosCommit: string, resolveResource: Capa
|
|||||||
compiled.resource.revision.migrationCatalog = catalog;
|
compiled.resource.revision.migrationCatalog = catalog;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
checkedResources++;
|
||||||
|
const queries =
|
||||||
|
compiled.resource.kind === "package" ? (compiled.resource.revision.checkedQueries?.length ?? 0) : 0;
|
||||||
|
checkedQueries += queries;
|
||||||
|
profile("resource-check", checkStarted, {
|
||||||
|
kind: locked.kind,
|
||||||
|
resource: compiled.resource.revision.displayName,
|
||||||
|
queries,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
kind: locked.kind,
|
kind: locked.kind,
|
||||||
@@ -274,7 +476,11 @@ const createResourceGraphResolver = (quixosCommit: string, resolveResource: Capa
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
visit,
|
visit,
|
||||||
nodes: async () => await Promise.all(resolved.values()),
|
nodes: async () => {
|
||||||
|
const nodes = await Promise.all(resolved.values());
|
||||||
|
profile("resource-graph", graphStarted, { checkedResources, reusedResources, checkedQueries });
|
||||||
|
return nodes;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -283,12 +489,19 @@ export const compileCapabilityResourceRepository = async (options: {
|
|||||||
kind: LockedResource["kind"];
|
kind: LockedResource["kind"];
|
||||||
source: GitSource;
|
source: GitSource;
|
||||||
resolveResource: CapabilityRepositoryResolver;
|
resolveResource: CapabilityRepositoryResolver;
|
||||||
|
generateQueries?: PackageQueryGenerator;
|
||||||
|
checkedResource?: CheckedResourceResolver;
|
||||||
}): Promise<CompiledCapabilityResourceRepository> => {
|
}): Promise<CompiledCapabilityResourceRepository> => {
|
||||||
const lockResult = await loadQuixosLock(path.join(options.rootDirectory, "quixos.lock"));
|
const lockResult = await loadQuixosLock(path.join(options.rootDirectory, "quixos.lock"));
|
||||||
if (!lockResult.ok) {
|
if (!lockResult.ok) {
|
||||||
throw new Error(diagnosticsMessage("Resource lock", lockResult.diagnostics));
|
throw new Error(diagnosticsMessage("Resource lock", lockResult.diagnostics));
|
||||||
}
|
}
|
||||||
const resolver = createResourceGraphResolver(lockResult.lock.quixos.commit, options.resolveResource);
|
const resolver = createResourceGraphResolver(
|
||||||
|
lockResult.lock.quixos.commit,
|
||||||
|
options.resolveResource,
|
||||||
|
options.generateQueries,
|
||||||
|
options.checkedResource,
|
||||||
|
);
|
||||||
const root = await resolver.visit(
|
const root = await resolver.visit(
|
||||||
{
|
{
|
||||||
kind: options.kind,
|
kind: options.kind,
|
||||||
@@ -301,6 +514,7 @@ export const compileCapabilityResourceRepository = async (options: {
|
|||||||
resource: root.resource,
|
resource: root.resource,
|
||||||
lock: root.lock,
|
lock: root.lock,
|
||||||
resources: await resolver.nodes(),
|
resources: await resolver.nodes(),
|
||||||
|
capabilityResources: resourceClosure([root], true),
|
||||||
directResources: root.dependencies,
|
directResources: root.dependencies,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -313,13 +527,20 @@ export const compileWorkspaceRepository = async (options: {
|
|||||||
sourceRootCommit?: string;
|
sourceRootCommit?: string;
|
||||||
/** An editor's proposed source snapshot; locks and dependency revisions remain exact. */
|
/** An editor's proposed source snapshot; locks and dependency revisions remain exact. */
|
||||||
readSource?: (name: string) => Promise<string>;
|
readSource?: (name: string) => Promise<string>;
|
||||||
|
generateQueries?: PackageQueryGenerator;
|
||||||
|
checkedResource?: CheckedResourceResolver;
|
||||||
}): Promise<CompiledWorkspaceRepository> => {
|
}): Promise<CompiledWorkspaceRepository> => {
|
||||||
const rootLockResult = await loadQuixosLock(path.join(options.rootDirectory, "quixos.lock"));
|
const rootLockResult = await loadQuixosLock(path.join(options.rootDirectory, "quixos.lock"));
|
||||||
if (!rootLockResult.ok) {
|
if (!rootLockResult.ok) {
|
||||||
throw new Error(diagnosticsMessage("Workspace lock", rootLockResult.diagnostics));
|
throw new Error(diagnosticsMessage("Workspace lock", rootLockResult.diagnostics));
|
||||||
}
|
}
|
||||||
const rootLock = rootLockResult.lock;
|
const rootLock = rootLockResult.lock;
|
||||||
const resolver = createResourceGraphResolver(rootLock.quixos.commit, options.resolveResource);
|
const resolver = createResourceGraphResolver(
|
||||||
|
rootLock.quixos.commit,
|
||||||
|
options.resolveResource,
|
||||||
|
options.generateQueries,
|
||||||
|
options.checkedResource,
|
||||||
|
);
|
||||||
|
|
||||||
const directPairs: Array<[LockedResource, ResolvedCapabilityResource]> = [];
|
const directPairs: Array<[LockedResource, ResolvedCapabilityResource]> = [];
|
||||||
for (const locked of rootLock.resources) {
|
for (const locked of rootLock.resources) {
|
||||||
@@ -345,11 +566,13 @@ export const compileWorkspaceRepository = async (options: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
assertImportsMatchLock(workspacePath, compiled.imports, rootLock.resources);
|
assertImportsMatchLock(workspacePath, compiled.imports, rootLock.resources);
|
||||||
const availableInterfaceIds = new Set(
|
const availableInterfaceIds = new Set((environment.interfaceClosure ?? []).map((iface) => iface.revisionId));
|
||||||
nodes.flatMap((node) => (node.resource.kind === "interface" ? [node.resource.revision.revisionId] : [])),
|
|
||||||
);
|
|
||||||
const availableAtomIds = new Set(compiled.workspace.atoms.map((atom) => atom.id));
|
const availableAtomIds = new Set(compiled.workspace.atoms.map((atom) => atom.id));
|
||||||
for (const node of nodes) {
|
for (const node of resourceClosure(
|
||||||
|
directPairs.map(([, node]) => node),
|
||||||
|
true,
|
||||||
|
)) {
|
||||||
|
if (node.resource.kind === "build") continue;
|
||||||
for (const requirement of node.resource.externalInterfaces) {
|
for (const requirement of node.resource.externalInterfaces) {
|
||||||
if (!availableInterfaceIds.has(requirement.revisionId)) {
|
if (!availableInterfaceIds.has(requirement.revisionId)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -381,6 +604,7 @@ export const compileWorkspaceRepository = async (options: {
|
|||||||
: {}),
|
: {}),
|
||||||
...(options.sourceRootCommit ? { sourceRootCommit: options.sourceRootCommit } : {}),
|
...(options.sourceRootCommit ? { sourceRootCommit: options.sourceRootCommit } : {}),
|
||||||
};
|
};
|
||||||
|
await options.generateQueries?.completeWorkspace?.(workspace);
|
||||||
const checked = compileWorkspaceRevision(workspace);
|
const checked = compileWorkspaceRevision(workspace);
|
||||||
if (!checked.ok) {
|
if (!checked.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -389,11 +613,20 @@ export const compileWorkspaceRepository = async (options: {
|
|||||||
.join("\n")}`,
|
.join("\n")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const linkedQueries = linkQueries(workspace);
|
||||||
|
if (linkedQueries.length) {
|
||||||
|
workspace.linkedQueries = linkedQueries;
|
||||||
|
checked.plan.source.linkedQueries = structuredClone(linkedQueries);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
workspace,
|
workspace,
|
||||||
plan: checked.plan,
|
plan: checked.plan,
|
||||||
lock: rootLock,
|
lock: rootLock,
|
||||||
resources: nodes,
|
resources: nodes,
|
||||||
|
capabilityResources: resourceClosure(
|
||||||
|
directPairs.map(([, node]) => node),
|
||||||
|
true,
|
||||||
|
),
|
||||||
directResources: new Map(directPairs.map(([locked, node]) => [bindingKey(locked.kind, locked.binding), node])),
|
directResources: new Map(directPairs.map(([locked, node]) => [bindingKey(locked.kind, locked.binding), node])),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import type { convergeAuthoring } from "./authoring-converge.js";
|
|||||||
import { execFile as callback } from "node:child_process";
|
import { execFile as callback } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { buildImmutableCandidate, checkerIdentity } from "./checked-build.js";
|
import { buildImmutableCandidate, checkerIdentity } from "./checked-build.js";
|
||||||
import { planEvolution, type WorkspaceRevision, type EvolutionReview } from "../capability-model/index.js";
|
import {
|
||||||
|
planEvolution,
|
||||||
|
reviewSemanticMajors,
|
||||||
|
type WorkspaceRevision,
|
||||||
|
type EvolutionReview,
|
||||||
|
} from "../capability-model/index.js";
|
||||||
|
|
||||||
export const checkRecordName = (directory: string) => createHash("sha256").update(directory).digest("hex") + ".json";
|
export const checkRecordName = (directory: string) => createHash("sha256").update(directory).digest("hex") + ".json";
|
||||||
export async function checkAuthoring(
|
export async function checkAuthoring(
|
||||||
@@ -34,6 +39,7 @@ export async function checkAuthoring(
|
|||||||
phase: string;
|
phase: string;
|
||||||
output: string;
|
output: string;
|
||||||
compilation?: "passed";
|
compilation?: "passed";
|
||||||
|
runtime?: "execution";
|
||||||
activationReadiness?: "preserve" | "migration-required" | "blocked";
|
activationReadiness?: "preserve" | "migration-required" | "blocked";
|
||||||
migrationRequired?: string[];
|
migrationRequired?: string[];
|
||||||
} = {
|
} = {
|
||||||
@@ -58,7 +64,7 @@ export async function checkAuthoring(
|
|||||||
// Repository-scoped agents can check separate immutable candidates in parallel.
|
// Repository-scoped agents can check separate immutable candidates in parallel.
|
||||||
const capture = promisify(callback)("quixos-qx", ["converge", context.workbench, directory], {
|
const capture = promisify(callback)("quixos-qx", ["converge", context.workbench, directory], {
|
||||||
maxBuffer: 4 * 1024 * 1024,
|
maxBuffer: 4 * 1024 * 1024,
|
||||||
env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_TRACE_CAPTURE: "1" },
|
env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_TRACE_CAPTURE: "1", QUIXOS_TRACE_PROFILE: "1" },
|
||||||
});
|
});
|
||||||
console.error(`Capture details: ${path.join(output, "capture.log")}`);
|
console.error(`Capture details: ${path.join(output, "capture.log")}`);
|
||||||
capture.child.stderr?.on("data", (chunk) => appendFileSync(path.join(output, "capture.log"), chunk));
|
capture.child.stderr?.on("data", (chunk) => appendFileSync(path.join(output, "capture.log"), chunk));
|
||||||
@@ -88,6 +94,10 @@ export async function checkAuthoring(
|
|||||||
resource.kind,
|
resource.kind,
|
||||||
path.join(output, "nix.log"),
|
path.join(output, "nix.log"),
|
||||||
options.contractOnly,
|
options.contractOnly,
|
||||||
|
(phase, ms) => {
|
||||||
|
timings[phase] = ms;
|
||||||
|
},
|
||||||
|
resource.kind === "workspace" ? context.workspaceId : undefined,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
timings.immutableCheckMs = Math.round(performance.now() - buildStarted);
|
timings.immutableCheckMs = Math.round(performance.now() - buildStarted);
|
||||||
@@ -98,7 +108,26 @@ export async function checkAuthoring(
|
|||||||
const candidateText = await fs.readFile(path.join(report.artifactPath, "candidate.json"), "utf8");
|
const candidateText = await fs.readFile(path.join(report.artifactPath, "candidate.json"), "utf8");
|
||||||
await fs.writeFile(path.join(output, "candidate.json"), candidateText);
|
await fs.writeFile(path.join(output, "candidate.json"), candidateText);
|
||||||
report.compilation = "passed";
|
report.compilation = "passed";
|
||||||
if (resource.kind === "workspace" && !options.contractOnly) {
|
try {
|
||||||
|
await fs.access(path.join(report.artifactPath, "execution-plan.json"));
|
||||||
|
report.runtime = "execution";
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
if (resource.kind === "workspace" && !options.contractOnly && report.runtime === "execution") {
|
||||||
|
report.phase = "semantic-review";
|
||||||
|
if (options.reviews && !options.baseline)
|
||||||
|
throw new Error("Execution semantic reviews require an explicit --baseline candidate");
|
||||||
|
const baseline = options.baseline
|
||||||
|
? (JSON.parse(await fs.readFile(options.baseline, "utf8")) as WorkspaceRevision)
|
||||||
|
: null;
|
||||||
|
const receipts = options.reviews
|
||||||
|
? (JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[])
|
||||||
|
: [];
|
||||||
|
const review = reviewSemanticMajors(baseline, JSON.parse(candidateText), receipts);
|
||||||
|
await fs.writeFile(path.join(output, "semantic-review.json"), JSON.stringify(review, null, 2));
|
||||||
|
report.blockers.push(...review.blockers);
|
||||||
|
} else if (resource.kind === "workspace" && !options.contractOnly) {
|
||||||
report.phase = "evolution";
|
report.phase = "evolution";
|
||||||
await progress();
|
await progress();
|
||||||
let baseline = options.baseline;
|
let baseline = options.baseline;
|
||||||
@@ -147,7 +176,7 @@ export async function checkAuthoring(
|
|||||||
await fs.writeFile(temporary, JSON.stringify(value, null, 2), { flag: "wx", mode: 0o600 });
|
await fs.writeFile(temporary, JSON.stringify(value, null, 2), { flag: "wx", mode: 0o600 });
|
||||||
await fs.rename(temporary, filename);
|
await fs.rename(temporary, filename);
|
||||||
};
|
};
|
||||||
if (report.artifactPath) {
|
if (report.artifactPath && report.compilation === "passed" && !report.blockers.length) {
|
||||||
const graph = JSON.parse(await fs.readFile(path.join(report.artifactPath, "graph.json"), "utf8"));
|
const graph = JSON.parse(await fs.readFile(path.join(report.artifactPath, "graph.json"), "utf8"));
|
||||||
for (const checked of graph.resources) {
|
for (const checked of graph.resources) {
|
||||||
const managed = context.resources.find(
|
const managed = context.resources.find(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
|||||||
import { loadQuixosLock, type GitSource } from "../resource-lock/index.js";
|
import { loadQuixosLock, type GitSource } from "../resource-lock/index.js";
|
||||||
|
|
||||||
export type AuthoringResource = {
|
export type AuthoringResource = {
|
||||||
kind: "workspace" | "interface" | "package";
|
kind: "workspace" | "interface" | "package" | "build";
|
||||||
directory: string;
|
directory: string;
|
||||||
resourceId?: string;
|
resourceId?: string;
|
||||||
source?: GitSource;
|
source?: GitSource;
|
||||||
@@ -20,38 +20,87 @@ export async function authoringContext(start: string) {
|
|||||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
}
|
}
|
||||||
if (text !== undefined) {
|
if (text !== undefined) {
|
||||||
const graph = JSON.parse(text) as { resources: AuthoringResource[] };
|
const graph = JSON.parse(text) as { resources: AuthoringResource[]; workspaceId?: string };
|
||||||
|
if (!graph || typeof graph !== "object" || Array.isArray(graph))
|
||||||
|
throw new Error("Managed resource inventory is malformed");
|
||||||
|
if (
|
||||||
|
graph.workspaceId !== undefined &&
|
||||||
|
(typeof graph.workspaceId !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9:._-]{0,255}$/.test(graph.workspaceId))
|
||||||
|
)
|
||||||
|
throw new Error("Managed workbench has an invalid Workspace identity");
|
||||||
if (!Array.isArray(graph.resources)) throw new Error("Managed resource inventory is malformed");
|
if (!Array.isArray(graph.resources)) throw new Error("Managed resource inventory is malformed");
|
||||||
const resources: AuthoringResource[] = [{ kind: "workspace", directory: "root" }];
|
const resources: AuthoringResource[] = [{ kind: "workspace", directory: "root" }];
|
||||||
const identities = new Set<string>(),
|
const identities = new Set<string>(),
|
||||||
directories = new Set<string>(["root"]);
|
directories = new Set<string>(["root"]);
|
||||||
for (const entry of graph.resources) {
|
for (const [index, entry] of graph.resources.entries()) {
|
||||||
// Compiler graphs carry resolved paths; the authoring API presents
|
const invalid = (reason: string): never => {
|
||||||
// stable workbench-relative names and validates containment here.
|
throw new Error(`Invalid managed resource registration at entry ${index + 1}: ${reason}`);
|
||||||
if (typeof entry.directory === "string" && path.isAbsolute(entry.directory))
|
};
|
||||||
entry.directory = path.relative(workbench, entry.directory);
|
|
||||||
if (
|
if (
|
||||||
!["interface", "package"].includes(entry.kind) ||
|
!entry ||
|
||||||
!entry.source ||
|
typeof entry !== "object" ||
|
||||||
!/^resources\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(entry.directory)
|
Array.isArray(entry) ||
|
||||||
) {
|
!["interface", "package", "build"].includes(entry.kind)
|
||||||
throw new Error("Invalid managed resource registration");
|
)
|
||||||
|
invalid("unsupported resource kind");
|
||||||
|
if (
|
||||||
|
entry.resourceId !== undefined &&
|
||||||
|
(typeof entry.resourceId !== "string" ||
|
||||||
|
entry.resourceId.length < 1 ||
|
||||||
|
entry.resourceId.length > 256 ||
|
||||||
|
entry.resourceId.includes("\0"))
|
||||||
|
)
|
||||||
|
invalid("resource identity must be a bounded nonempty identifier");
|
||||||
|
const source = entry.source;
|
||||||
|
if (
|
||||||
|
!source ||
|
||||||
|
typeof source !== "object" ||
|
||||||
|
Array.isArray(source) ||
|
||||||
|
source.resolver !== "git" ||
|
||||||
|
typeof source.repository !== "string" ||
|
||||||
|
!source.repository ||
|
||||||
|
source.repository.length > 4096 ||
|
||||||
|
source.repository.includes("\0") ||
|
||||||
|
typeof source.commit !== "string" ||
|
||||||
|
!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(source.commit)
|
||||||
|
)
|
||||||
|
invalid("expected an immutable Git source");
|
||||||
|
// Git transport policy belongs to the source resolver, including its
|
||||||
|
// maintained local/file fixtures; registration validates only its shape.
|
||||||
|
if (
|
||||||
|
typeof entry.directory !== "string" ||
|
||||||
|
!entry.directory ||
|
||||||
|
entry.directory.length > 4096 ||
|
||||||
|
entry.directory.includes("\0")
|
||||||
|
)
|
||||||
|
invalid("directory must be a bounded path");
|
||||||
|
// Bootstrap intentionally records stable aliases for native histories.
|
||||||
|
// Compare physical paths on BOTH sides, then expose the same canonical
|
||||||
|
// workbench-relative selection for alias and physical working dirs.
|
||||||
|
let directory: string;
|
||||||
|
try {
|
||||||
|
directory = path.relative(workbench, await realpath(path.resolve(workbench, entry.directory)));
|
||||||
|
} catch {
|
||||||
|
invalid("registered directory is unavailable");
|
||||||
}
|
}
|
||||||
const identity = `${entry.kind}\0${entry.resourceId ?? entry.source.repository}`;
|
if (!/^resources\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(directory!))
|
||||||
if (identities.has(identity) || directories.has(entry.directory)) {
|
invalid("registered directory must resolve beneath workbench resources");
|
||||||
throw new Error(`Multiple editable selections for ${entry.resourceId ?? entry.source.repository}`);
|
const identity = `${entry.kind}\0${entry.resourceId ?? source!.repository}`;
|
||||||
|
if (identities.has(identity) || directories.has(directory!)) {
|
||||||
|
throw new Error(`Multiple editable selections for ${directory!}`);
|
||||||
}
|
}
|
||||||
identities.add(identity);
|
identities.add(identity);
|
||||||
directories.add(entry.directory);
|
directories.add(directory!);
|
||||||
resources.push({
|
resources.push({
|
||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
directory: entry.directory,
|
directory: directory!,
|
||||||
resourceId: entry.resourceId,
|
resourceId: entry.resourceId,
|
||||||
source: entry.source,
|
source: entry.source,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
workbench,
|
workbench,
|
||||||
|
workspaceId: graph.workspaceId,
|
||||||
resources,
|
resources,
|
||||||
async baseline() {
|
async baseline() {
|
||||||
const result = await loadQuixosLock(path.join(workbench, "root/quixos.lock"));
|
const result = await loadQuixosLock(path.join(workbench, "root/quixos.lock"));
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { execFile as callback } from "node:child_process";
|
|||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { authoringContext } from "./authoring-context.js";
|
import { authoringContext } from "./authoring-context.js";
|
||||||
|
import { managedResourcePath } from "./managed-source-path.js";
|
||||||
import { snapshotCommit } from "./checked-build.js";
|
import { snapshotCommit } from "./checked-build.js";
|
||||||
|
import { profile } from "./profile.js";
|
||||||
import {
|
import {
|
||||||
loadQuixosLock,
|
loadQuixosLock,
|
||||||
parseQuixosLockDocument,
|
parseQuixosLockDocument,
|
||||||
@@ -14,6 +16,42 @@ import {
|
|||||||
} from "../resource-lock/index.js";
|
} from "../resource-lock/index.js";
|
||||||
|
|
||||||
const execFile = promisify(callback);
|
const execFile = promisify(callback);
|
||||||
|
// Bound independent repository operations. One visit owns each repository;
|
||||||
|
// all visits finish before the final concurrent-edit validation pass.
|
||||||
|
const concurrency = 4;
|
||||||
|
async function mapConcurrent<T, R>(values: readonly T[], action: (value: T) => Promise<R>): Promise<R[]> {
|
||||||
|
const results = new Array<R>(values.length);
|
||||||
|
let next = 0;
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(concurrency, values.length) }, async () => {
|
||||||
|
while (next < values.length) {
|
||||||
|
const index = next++;
|
||||||
|
results[index] = await action(values[index]);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
const limiter = () => {
|
||||||
|
let running = 0;
|
||||||
|
const waiting: Array<() => void> = [];
|
||||||
|
return async <T>(action: () => Promise<T>): Promise<T> => {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (running < concurrency) {
|
||||||
|
running++;
|
||||||
|
resolve();
|
||||||
|
} else waiting.push(resolve);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await action();
|
||||||
|
} finally {
|
||||||
|
const next = waiting.shift();
|
||||||
|
if (next) next();
|
||||||
|
else running--;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const command = async (cwd: string, executable: string, args: string[]) => {
|
const command = async (cwd: string, executable: string, args: string[]) => {
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
// Do not log arguments: transports may contain credentials. Source identities
|
// Do not log arguments: transports may contain credentials. Source identities
|
||||||
@@ -44,13 +82,19 @@ export type AuthoringBlocker = {
|
|||||||
* worklist grants typechecking, semantic review or activation approval.
|
* worklist grants typechecking, semantic review or activation approval.
|
||||||
* Caller serializes coordinators; package authors may still be editing. */
|
* Caller serializes coordinators; package authors may still be editing. */
|
||||||
export async function convergeAuthoring(start: string, target = "root") {
|
export async function convergeAuthoring(start: string, target = "root") {
|
||||||
|
const started = performance.now();
|
||||||
const context = await authoringContext(start);
|
const context = await authoringContext(start);
|
||||||
const graphFile = path.join(context.workbench, ".quixos/resource-graph.json");
|
const graphFile = path.join(context.workbench, ".quixos/resource-graph.json");
|
||||||
const graphBefore = await readFile(graphFile, "utf8");
|
const graphBefore = await readFile(graphFile, "utf8");
|
||||||
|
const graph = JSON.parse(graphBefore);
|
||||||
|
// Discovery resolves outer guest aliases. Persist those same physical
|
||||||
|
// selections, without accepting symlinks within the managed workbench.
|
||||||
|
for (const resource of graph.resources)
|
||||||
|
resource.directory = await managedResourcePath(context.workbench, resource.directory);
|
||||||
const blockers: AuthoringBlocker[] = [];
|
const blockers: AuthoringBlocker[] = [];
|
||||||
const nodes = new Map<string, { directory: string; kind: string; source: GitSource; dependencies: string[] }>();
|
const nodes = new Map<string, { directory: string; kind: string; source: GitSource; dependencies: string[] }>();
|
||||||
const selected = new Map<string, string>();
|
const selected = new Map<string, string>();
|
||||||
for (const entry of context.resources) {
|
const inventory = await mapConcurrent(context.resources, async (entry) => {
|
||||||
const root = path.join(context.workbench, entry.directory);
|
const root = path.join(context.workbench, entry.directory);
|
||||||
let repository = entry.source?.repository;
|
let repository = entry.source?.repository;
|
||||||
try {
|
try {
|
||||||
@@ -64,6 +108,9 @@ export async function convergeAuthoring(start: string, target = "root") {
|
|||||||
blockers.push({ directory: entry.directory, phase: "source", message: String(error).slice(0, 2000) });
|
blockers.push({ directory: entry.directory, phase: "source", message: String(error).slice(0, 2000) });
|
||||||
if (!repository) throw error; // The root has no separate registered source.
|
if (!repository) throw error; // The root has no separate registered source.
|
||||||
}
|
}
|
||||||
|
return { ...entry, repository };
|
||||||
|
});
|
||||||
|
for (const { repository, ...entry } of inventory) {
|
||||||
const key = identity(entry.kind, repository);
|
const key = identity(entry.kind, repository);
|
||||||
if (selected.has(key)) throw new Error(`More than one editable checkout for ${repository}`);
|
if (selected.has(key)) throw new Error(`More than one editable checkout for ${repository}`);
|
||||||
selected.set(key, entry.directory);
|
selected.set(key, entry.directory);
|
||||||
@@ -73,7 +120,7 @@ export async function convergeAuthoring(start: string, target = "root") {
|
|||||||
dependencies: [],
|
dependencies: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const node of nodes.values()) {
|
await mapConcurrent([...nodes.values()], async (node) => {
|
||||||
try {
|
try {
|
||||||
const lock = await loadQuixosLock(path.join(context.workbench, node.directory, "quixos.lock"));
|
const lock = await loadQuixosLock(path.join(context.workbench, node.directory, "quixos.lock"));
|
||||||
if (!lock.ok) throw new Error(lock.diagnostics.map((d) => `${d.fileName}: ${d.message}`).join("\n"));
|
if (!lock.ok) throw new Error(lock.diagnostics.map((d) => `${d.fileName}: ${d.message}`).join("\n"));
|
||||||
@@ -88,31 +135,43 @@ export async function convergeAuthoring(start: string, target = "root") {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
blockers.push({ directory: node.directory, phase: "resolution", message: String(error) });
|
blockers.push({ directory: node.directory, phase: "resolution", message: String(error) });
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
const complete = new Map<string, GitSource>(),
|
// Detect cycles before starting concurrent futures: two roots awaiting each
|
||||||
active = new Set<string>();
|
// other must fail clearly rather than deadlocking on their cached promises.
|
||||||
const visited = new Set<string>();
|
const scanned = new Set<string>();
|
||||||
if (!nodes.has(target)) throw new Error(`Not a registered repository: ${target}`);
|
const detectCycles = (directory: string, parents: string[] = []) => {
|
||||||
const visit = async (directory: string): Promise<boolean> => {
|
if (parents.includes(directory)) {
|
||||||
visited.add(directory);
|
|
||||||
if (complete.has(directory)) return true;
|
|
||||||
if (blockers.some((entry) => entry.directory === directory)) return false;
|
|
||||||
if (active.has(directory)) {
|
|
||||||
blockers.push({
|
blockers.push({
|
||||||
directory,
|
directory,
|
||||||
phase: "dependency",
|
phase: "dependency",
|
||||||
message: `Source dependency cycle: ${[...active, directory].join(" -> ")}`,
|
message: `Source dependency cycle: ${[...parents, directory].join(" -> ")}`,
|
||||||
});
|
});
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
active.add(directory);
|
if (scanned.has(directory)) return;
|
||||||
|
for (const dependency of nodes.get(directory)!.dependencies) detectCycles(dependency, [...parents, directory]);
|
||||||
|
scanned.add(directory);
|
||||||
|
};
|
||||||
|
for (const directory of nodes.keys()) detectCycles(directory);
|
||||||
|
const complete = new Map<string, GitSource>();
|
||||||
|
const pending = new Map<string, Promise<boolean>>();
|
||||||
|
const capture = limiter();
|
||||||
|
const visited = new Set<string>();
|
||||||
|
if (!nodes.has(target)) throw new Error(`Not a registered repository: ${target}`);
|
||||||
|
const visit = (directory: string): Promise<boolean> => {
|
||||||
|
visited.add(directory);
|
||||||
|
const existing = pending.get(directory);
|
||||||
|
if (existing) return existing;
|
||||||
|
const result = (async () => {
|
||||||
|
if (blockers.some((entry) => entry.directory === directory)) return false;
|
||||||
const node = nodes.get(directory)!;
|
const node = nodes.get(directory)!;
|
||||||
for (const dependency of node.dependencies)
|
const ready = await Promise.all(node.dependencies.map(visit));
|
||||||
if (!(await visit(dependency))) {
|
const unavailable = node.dependencies.find((_, index) => !ready[index]);
|
||||||
blockers.push({ directory, phase: "dependency", message: `Waiting for ${dependency}` });
|
if (unavailable) {
|
||||||
active.delete(directory);
|
blockers.push({ directory, phase: "dependency", message: `Waiting for ${unavailable}` });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
return await capture(async () => {
|
||||||
const root = path.join(context.workbench, directory);
|
const root = path.join(context.workbench, directory);
|
||||||
let phase: AuthoringBlocker["phase"] = "source";
|
let phase: AuthoringBlocker["phase"] = "source";
|
||||||
try {
|
try {
|
||||||
@@ -128,7 +187,9 @@ export async function convergeAuthoring(start: string, target = "root") {
|
|||||||
const target = selected.get(identity(dependency.kind, dependency.source.repository));
|
const target = selected.get(identity(dependency.kind, dependency.source.repository));
|
||||||
const source = target ? complete.get(target) : undefined;
|
const source = target ? complete.get(target) : undefined;
|
||||||
if (target && !source)
|
if (target && !source)
|
||||||
throw new Error(`Dependencies changed during convergence (${dependency.binding}); join writers and retry`);
|
throw new Error(
|
||||||
|
`Dependencies changed during convergence (${dependency.binding}); join writers and retry`,
|
||||||
|
);
|
||||||
if (source && source.commit !== dependency.source.commit) {
|
if (source && source.commit !== dependency.source.commit) {
|
||||||
dependency.source = source;
|
dependency.source = source;
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -155,35 +216,43 @@ export async function convergeAuthoring(start: string, target = "root") {
|
|||||||
phase = "publication";
|
phase = "publication";
|
||||||
const ref = retentionTagForCommit(commit);
|
const ref = retentionTagForCommit(commit);
|
||||||
const remote = await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref]);
|
const remote = await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref]);
|
||||||
if (remote && remote.split(/\s+/)[0] !== commit) throw new Error(`Conflicting immutable retention ref ${ref}`);
|
if (remote && remote.split(/\s+/)[0] !== commit)
|
||||||
if (!remote) await command(root, "git", ["push", node.source.repository, `${commit}:${ref}`]);
|
throw new Error(`Conflicting immutable retention ref ${ref}`);
|
||||||
if ((await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref])).split(/\s+/)[0] !== commit)
|
if (!remote) {
|
||||||
|
await command(root, "git", ["push", node.source.repository, `${commit}:${ref}`]);
|
||||||
|
if (
|
||||||
|
(await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref])).split(/\s+/)[0] !==
|
||||||
|
commit
|
||||||
|
)
|
||||||
throw new Error("Published source retention was not observed");
|
throw new Error("Published source retention was not observed");
|
||||||
|
}
|
||||||
complete.set(directory, { ...node.source, commit });
|
complete.set(directory, { ...node.source, commit });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
blockers.push({ directory, phase, message: String(error).slice(0, 4000) });
|
blockers.push({ directory, phase, message: String(error).slice(0, 4000) });
|
||||||
}
|
}
|
||||||
active.delete(directory);
|
|
||||||
return complete.has(directory);
|
return complete.has(directory);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
pending.set(directory, result);
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
// Include newly created, not-yet-imported resources, then the root.
|
// Include newly created, not-yet-imported resources, then the root.
|
||||||
if (target === "root") for (const directory of [...nodes.keys()].filter((d) => d !== "root")) await visit(directory);
|
if (target === "root") await Promise.all([...nodes.keys()].filter((d) => d !== "root").map(visit));
|
||||||
await visit(target);
|
await visit(target);
|
||||||
for (let index = blockers.length - 1; index >= 0; index--)
|
for (let index = blockers.length - 1; index >= 0; index--)
|
||||||
if (!visited.has(blockers[index].directory)) blockers.splice(index, 1);
|
if (!visited.has(blockers[index].directory)) blockers.splice(index, 1);
|
||||||
for (const [directory, source] of complete) {
|
const recheckStarted = performance.now();
|
||||||
|
await mapConcurrent([...complete], async ([directory, source]) => {
|
||||||
try {
|
try {
|
||||||
if ((await snapshotCommit(path.join(context.workbench, directory))) !== source.commit)
|
if ((await snapshotCommit(path.join(context.workbench, directory))) !== source.commit)
|
||||||
throw new Error("Source advanced while converging; join writers and retry");
|
throw new Error("Source advanced while converging; join writers and retry");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
blockers.push({ directory, phase: "concurrent-edit", message: String(error) });
|
blockers.push({ directory, phase: "concurrent-edit", message: String(error) });
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
profile("capture-recheck", recheckStarted, { repositories: complete.size });
|
||||||
// Persist successful selections even if another repository is still broken.
|
// Persist successful selections even if another repository is still broken.
|
||||||
// Recovery must not depend on all parents succeeding in the same invocation.
|
// Recovery must not depend on all parents succeeding in the same invocation.
|
||||||
const graph = JSON.parse(graphBefore);
|
|
||||||
for (const resource of graph.resources)
|
|
||||||
resource.directory = path.relative(context.workbench, path.resolve(context.workbench, resource.directory));
|
|
||||||
const replacements = new Map<string, string>();
|
const replacements = new Map<string, string>();
|
||||||
for (const resource of graph.resources) {
|
for (const resource of graph.resources) {
|
||||||
const source = complete.get(resource.directory);
|
const source = complete.get(resource.directory);
|
||||||
@@ -238,11 +307,14 @@ export async function convergeAuthoring(start: string, target = "root") {
|
|||||||
await rm(temporary, { force: true });
|
await rm(temporary, { force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
profile("capture", started, { repositories: nodes.size, snapshots: complete.size * 2, target });
|
||||||
return {
|
return {
|
||||||
workbench: context.workbench,
|
workbench: context.workbench,
|
||||||
converged: blockers.length === 0,
|
converged: blockers.length === 0,
|
||||||
candidate: blockers.length ? null : (complete.get(target) ?? null),
|
candidate: blockers.length ? null : (complete.get(target) ?? null),
|
||||||
retained: [...complete].map(([directory, source]) => ({ directory, source })),
|
retained: [...complete]
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([directory, source]) => ({ directory, source })),
|
||||||
worklist: blockers,
|
worklist: blockers,
|
||||||
verificationEvidence: false,
|
verificationEvidence: false,
|
||||||
activated: false,
|
activated: false,
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ export async function inspectAuthoringRepository(root: string, historyLimit = 10
|
|||||||
declarations: syntax
|
declarations: syntax
|
||||||
? [...walkSyntax(syntax.root)]
|
? [...walkSyntax(syntax.root)]
|
||||||
.filter((node) =>
|
.filter((node) =>
|
||||||
/^(?:interface|package|atom|state|edge|method|function|event|conformance)\w*Decl$/.test(node.kind),
|
/^(?:(?:interface|package|atom|state|edge|method|function|event|conformance|presentation)\w*Decl|module(?:Member|Export))$/.test(
|
||||||
|
node.kind,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.map((node) => ({ kind: node.kind, source: source.slice(node.start, node.end) }))
|
.map((node) => ({ kind: node.kind, source: source.slice(node.start, node.end) }))
|
||||||
: [],
|
: [],
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ export const checkWorkspaceCandidate = async (options: {
|
|||||||
: [];
|
: [];
|
||||||
const evolution = planEvolution(baseline, compiled.workspace, { reviews });
|
const evolution = planEvolution(baseline, compiled.workspace, { reviews });
|
||||||
blockers.push(...evolution.blockers);
|
blockers.push(...evolution.blockers);
|
||||||
for (const resource of compiled.resources.filter((entry) => entry.kind === "package")) {
|
for (const resource of compiled.capabilityResources.filter((entry) => entry.kind === "package")) {
|
||||||
// Per-package recursive schema, identical to host activation, not unrelated
|
// Per-package recursive schema, identical to host activation, not unrelated
|
||||||
// workspace declarations that would unnecessarily invalidate build caches.
|
// workspace declarations that would unnecessarily invalidate build caches.
|
||||||
const candidate = await compileCapabilityResourceRepository({
|
const candidate = await compileCapabilityResourceRepository({
|
||||||
@@ -218,6 +218,7 @@ export const checkWorkspaceCandidate = async (options: {
|
|||||||
source: resource.source,
|
source: resource.source,
|
||||||
resolveResource,
|
resolveResource,
|
||||||
});
|
});
|
||||||
|
if (candidate.resource.kind !== "package") throw Error("Expected checked package contract");
|
||||||
const schema = path.join(temporary, "bindings.json");
|
const schema = path.join(temporary, "bindings.json");
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
schema,
|
schema,
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ export const checkerIdentity = () =>
|
|||||||
export async function snapshotCommit(root: string): Promise<string> {
|
export async function snapshotCommit(root: string): Promise<string> {
|
||||||
const run = async (...args: string[]) =>
|
const run = async (...args: string[]) =>
|
||||||
(await execFile("jj", args, { cwd: root, env: environment() })).stdout.trim();
|
(await execFile("jj", args, { cwd: root, env: environment() })).stdout.trim();
|
||||||
await run("status");
|
// A single normal log snapshots the working copy and observes both values
|
||||||
// jj resolve --list exits 1 on a clean revision. Query structured revision
|
// from that exact revision. Separate status/log processes add no evidence.
|
||||||
// metadata instead of depending on diagnostic wording or swallowing errors.
|
const [conflict, commit] = (await run("log", "--no-graph", "-r", "@", "-T", 'conflict ++ "\\n" ++ commit_id')).split(
|
||||||
if ((await run("--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "conflict")) !== "false")
|
"\n",
|
||||||
throw new Error("Resolve source conflicts before verification");
|
);
|
||||||
const commit = await run("--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id");
|
if (conflict !== "false") throw new Error("Resolve source conflicts before verification");
|
||||||
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) throw new Error("Verification requires an exact jj commit");
|
if (!commit || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit))
|
||||||
|
throw new Error("Verification requires an exact jj commit");
|
||||||
await execFile("git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"], {
|
await execFile("git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: environment(),
|
env: environment(),
|
||||||
@@ -107,15 +108,27 @@ export async function buildCheckedPackage(source: string, schema: string, packag
|
|||||||
* nested fetches. Keep build noise in a named log, with a bounded failure tail. */
|
* nested fetches. Keep build noise in a named log, with a bounded failure tail. */
|
||||||
export async function buildImmutableCandidate(
|
export async function buildImmutableCandidate(
|
||||||
source: { repository: string; commit: string },
|
source: { repository: string; commit: string },
|
||||||
kind: "workspace" | "interface" | "package",
|
kind: "workspace" | "interface" | "package" | "build",
|
||||||
logFile: string,
|
logFile: string,
|
||||||
contractOnly = false,
|
contractOnly = false,
|
||||||
|
observe?: (phase: "nixEvaluationMs" | "nixBuildMs", ms: number) => void,
|
||||||
|
workspaceId?: string,
|
||||||
|
platformSource?: { repository: string; commit: string; directory: string },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
if (workspaceId !== undefined && (kind !== "workspace" || !/^[a-zA-Z0-9][a-zA-Z0-9:._-]{0,255}$/.test(workspaceId)))
|
||||||
|
throw new Error("Instance binding requires a valid Workspace identity and a Workspace root");
|
||||||
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(source.commit))
|
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(source.commit))
|
||||||
throw new Error("An immutable candidate requires an exact commit");
|
throw new Error("An immutable candidate requires an exact commit");
|
||||||
const url = new URL(source.repository);
|
const url = new URL(source.repository);
|
||||||
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash)
|
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash)
|
||||||
throw new Error("Candidate origin must be credential-free HTTPS");
|
throw new Error("Candidate origin must be credential-free HTTPS");
|
||||||
|
if (
|
||||||
|
platformSource &&
|
||||||
|
(Object.keys(platformSource).sort().join(",") !== "commit,directory,repository" ||
|
||||||
|
!/^[a-f0-9]{40}$/.test(platformSource.commit) ||
|
||||||
|
!/^\/nix\/store\/[a-z0-9]{32}-[^/\s]+$/.test(platformSource.directory))
|
||||||
|
)
|
||||||
|
throw Error("Invalid immutable platform source input");
|
||||||
const generator =
|
const generator =
|
||||||
process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Use the installed Quixos checker");
|
if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Use the installed Quixos checker");
|
||||||
@@ -126,16 +139,62 @@ export async function buildImmutableCandidate(
|
|||||||
log.once("open", () => resolve());
|
log.once("open", () => resolve());
|
||||||
log.once("error", reject);
|
log.once("error", reject);
|
||||||
});
|
});
|
||||||
return await new Promise((resolve, reject) => {
|
let logBytes = 0;
|
||||||
|
const run = async (executable: string, args: string[]) =>
|
||||||
|
await new Promise<string>((resolve, reject) => {
|
||||||
let output = "",
|
let output = "",
|
||||||
tail = "",
|
tail = "",
|
||||||
failure: Error | undefined;
|
failure: Error | undefined;
|
||||||
const child = spawn(
|
const child = spawn(executable, args, { env: environment(), stdio: ["ignore", "pipe", "pipe"] });
|
||||||
"nix",
|
const failedLog = (error: Error) => {
|
||||||
[
|
failure = error;
|
||||||
"build",
|
child.kill();
|
||||||
"--impure",
|
};
|
||||||
"--file",
|
log.on("error", failedLog);
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
if (failure) return;
|
||||||
|
if (Buffer.byteLength(output) + chunk.length > 65536)
|
||||||
|
failedLog(new Error("Candidate checker output exceeds 64 KiB"));
|
||||||
|
else output += chunk;
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
if (failure) return;
|
||||||
|
logBytes += chunk.length;
|
||||||
|
if (logBytes > 16 * 1024 * 1024) {
|
||||||
|
failedLog(new Error("Candidate checker log exceeds 16 MiB"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.write(chunk);
|
||||||
|
tail = (tail + String(chunk)).slice(-6000);
|
||||||
|
});
|
||||||
|
child.on("error", (error) => {
|
||||||
|
failure = error;
|
||||||
|
});
|
||||||
|
child.on("close", (code) => {
|
||||||
|
log.off("error", failedLog);
|
||||||
|
if (failure) reject(failure);
|
||||||
|
else if (code !== 0) reject(new Error(`Candidate Nix check failed (${code}). Full log: ${logFile}\n${tail}`));
|
||||||
|
else resolve(output.trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const timed = async (phase: "nixEvaluationMs" | "nixBuildMs", action: () => Promise<string>) => {
|
||||||
|
const started = performance.now();
|
||||||
|
try {
|
||||||
|
return await action();
|
||||||
|
} finally {
|
||||||
|
observe?.(phase, Math.round(performance.now() - started));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let gcDirectory: string | undefined;
|
||||||
|
try {
|
||||||
|
// Evaluation includes import-from-derivation builds (locks and contracts).
|
||||||
|
// Build the resulting derivation directly, without evaluating the DAG twice.
|
||||||
|
// Keep the derivation alive across the two processes, including concurrent
|
||||||
|
// GC. This temporary root is removed on success and on verification failure.
|
||||||
|
gcDirectory = await fs.mkdtemp(path.join(path.dirname(logFile), ".gc-root-"));
|
||||||
|
const gcRoot = path.join(gcDirectory, "candidate");
|
||||||
|
const derivation = await timed("nixEvaluationMs", async () => {
|
||||||
|
await run("nix-instantiate", [
|
||||||
builder,
|
builder,
|
||||||
"--argstr",
|
"--argstr",
|
||||||
"repository",
|
"repository",
|
||||||
@@ -152,33 +211,24 @@ export async function buildImmutableCandidate(
|
|||||||
"--arg",
|
"--arg",
|
||||||
"contractOnly",
|
"contractOnly",
|
||||||
contractOnly ? "true" : "false",
|
contractOnly ? "true" : "false",
|
||||||
"--no-link",
|
...(workspaceId === undefined ? [] : ["--argstr", "workspaceId", workspaceId]),
|
||||||
"--print-out-paths",
|
...(platformSource ? ["--argstr", "platformSourceJSON", JSON.stringify(platformSource)] : []),
|
||||||
"-L",
|
"--add-root",
|
||||||
],
|
gcRoot,
|
||||||
{ env: environment(), stdio: ["ignore", "pipe", "pipe"] },
|
"--indirect",
|
||||||
|
]);
|
||||||
|
return await fs.readlink(gcRoot);
|
||||||
|
});
|
||||||
|
if (!/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+\.drv$/.test(derivation))
|
||||||
|
throw new Error("Nix returned an invalid candidate derivation");
|
||||||
|
const artifact = await timed("nixBuildMs", () =>
|
||||||
|
run("nix", ["build", derivation + "^*", "--no-link", "--print-out-paths", "-L"]),
|
||||||
);
|
);
|
||||||
log.on("error", (error) => {
|
if (!/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(artifact))
|
||||||
failure = error;
|
throw new Error("Nix returned an invalid candidate artifact");
|
||||||
child.kill();
|
return artifact;
|
||||||
});
|
} finally {
|
||||||
child.stdout.on("data", (chunk) => {
|
await new Promise<void>((resolve) => log.end(resolve));
|
||||||
output += chunk;
|
if (gcDirectory) await fs.rm(gcDirectory, { recursive: true, force: true });
|
||||||
});
|
}
|
||||||
child.stderr.on("data", (chunk) => {
|
|
||||||
log.write(chunk);
|
|
||||||
tail = (tail + String(chunk)).slice(-6000);
|
|
||||||
});
|
|
||||||
child.on("error", (error) => {
|
|
||||||
failure = error;
|
|
||||||
});
|
|
||||||
child.on("close", (code) =>
|
|
||||||
log.end(() => {
|
|
||||||
if (failure) reject(failure);
|
|
||||||
else if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(output.trim()))
|
|
||||||
reject(new Error(`Candidate Nix check failed (${code}). Full log: ${logFile}\n${tail}`));
|
|
||||||
else resolve(output.trim());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import type { CheckedResourceResolver } from "./assembly.js";
|
||||||
|
import type { CapabilityResource } from "./parser.js";
|
||||||
|
|
||||||
|
/** Internal compiler inputs produced by checked-candidate.nix, not authoring
|
||||||
|
* receipts. The Nix composition owns their derivations and exact toolchain.
|
||||||
|
* Ordinary compiler callers omit this map and run all checks directly. */
|
||||||
|
export async function loadCheckedResources(filename?: string): Promise<CheckedResourceResolver | undefined> {
|
||||||
|
if (!filename) return undefined;
|
||||||
|
const entries = JSON.parse(await readFile(filename, "utf8")) as Array<{
|
||||||
|
kind: string;
|
||||||
|
repository: string;
|
||||||
|
commit: string;
|
||||||
|
artifact: string;
|
||||||
|
}>;
|
||||||
|
const resources = new Map<string, string>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const key = `${entry.kind}\0${entry.repository}\0${entry.commit}`;
|
||||||
|
if (resources.has(key)) throw new Error("Repeated checked resource input");
|
||||||
|
if (!/^\/nix\/store\/[^/]+$/.test(entry.artifact))
|
||||||
|
throw new Error("Checked resources must be immutable Nix outputs");
|
||||||
|
resources.set(key, entry.artifact);
|
||||||
|
}
|
||||||
|
return async (kind, source, lock, dependencies) => {
|
||||||
|
const artifact = resources.get(`${kind}\0${source.repository}\0${source.commit}`);
|
||||||
|
if (!artifact) return undefined;
|
||||||
|
const graph = JSON.parse(await readFile(`${artifact}/graph.json`, "utf8"));
|
||||||
|
const expected = [...dependencies].map(([binding, node]) => ({ binding, resourceKey: node.key }));
|
||||||
|
if (
|
||||||
|
graph.quixos.commit !== lock.quixos.commit ||
|
||||||
|
graph.quixos.repository !== lock.quixos.repository ||
|
||||||
|
graph.root.kind !== kind ||
|
||||||
|
graph.root.source.repository !== source.repository ||
|
||||||
|
graph.root.source.commit !== source.commit ||
|
||||||
|
JSON.stringify(graph.root.dependencies) !== JSON.stringify(expected)
|
||||||
|
)
|
||||||
|
throw new Error("Checked resource input does not match its locked dependency graph");
|
||||||
|
return JSON.parse(await readFile(`${artifact}/candidate.json`, "utf8")) as CapabilityResource;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
export const nixLiteral = (value: string): string => JSON.stringify(value).replace(/\$\{/g, "\\${");
|
||||||
|
|
||||||
|
/** Recognize the deliberately small scaffold edit surface, never evaluate Nix.
|
||||||
|
* Authors may replace it with arbitrary Nix; then scaffolding declines the edit. */
|
||||||
|
export async function componentEntries(source: string): Promise<Record<string, { entry: string; export: string }>> {
|
||||||
|
const { stdout } = await execFile("nix-instantiate", ["--parse", "--expr", source], { maxBuffer: 1024 * 1024 });
|
||||||
|
const tokens = stdout.match(/"(?:\\.|[^"\\])*"|[A-Za-z_][A-Za-z0-9_-]*|[{}=;]/g) ?? [];
|
||||||
|
if (stdout.replace(/"(?:\\.|[^"\\])*"|[A-Za-z_][A-Za-z0-9_-]*|[{}=;]|\s+/g, ""))
|
||||||
|
throw Error("components.nix is outside the supported literal-entry shape");
|
||||||
|
let cursor = 0;
|
||||||
|
const expect = (token: string) => {
|
||||||
|
if (tokens[cursor++] !== token) throw Error("components.nix is outside the supported literal-entry shape");
|
||||||
|
};
|
||||||
|
const string = () => {
|
||||||
|
const token = tokens[cursor++];
|
||||||
|
if (!token?.startsWith('"')) throw Error("Component entries must use literal strings");
|
||||||
|
const json = token.replace(/(\\*)\$\{/g, (_match, escapes: string) => {
|
||||||
|
if (escapes.length % 2 === 0) throw Error("Component entries must use literal strings");
|
||||||
|
return escapes.slice(1) + "${";
|
||||||
|
});
|
||||||
|
return JSON.parse(json) as string;
|
||||||
|
};
|
||||||
|
const entries: Record<string, { entry: string; export: string }> = Object.create(null);
|
||||||
|
expect("{");
|
||||||
|
while (tokens[cursor] !== "}") {
|
||||||
|
const id = tokens[cursor]?.startsWith('"') ? string() : tokens[cursor++];
|
||||||
|
if (!id) throw Error("Missing component identity");
|
||||||
|
if (Object.hasOwn(entries, id)) throw Error("Duplicate component entry");
|
||||||
|
expect("=");
|
||||||
|
expect("{");
|
||||||
|
const properties: Record<string, string> = Object.create(null);
|
||||||
|
while (tokens[cursor] !== "}") {
|
||||||
|
const key = tokens[cursor]?.startsWith('"') ? string() : tokens[cursor++];
|
||||||
|
if (!key || !["entry", "export"].includes(key) || Object.hasOwn(properties, key))
|
||||||
|
throw Error("Unsupported component entry property");
|
||||||
|
expect("=");
|
||||||
|
properties[key] = string();
|
||||||
|
expect(";");
|
||||||
|
}
|
||||||
|
expect("}");
|
||||||
|
expect(";");
|
||||||
|
if (!properties.entry || !properties.export) throw Error("Component entry needs entry and export");
|
||||||
|
entries[id] = { entry: properties.entry, export: properties.export };
|
||||||
|
}
|
||||||
|
expect("}");
|
||||||
|
if (cursor !== tokens.length || !/\n}\s*$/.test(source))
|
||||||
|
throw Error("components.nix is outside the supported scaffold shape");
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { parseQx, walkSyntax } from "./source.js";
|
||||||
|
import type { ScaffoldRecipe } from "./scaffold-recipes.js";
|
||||||
|
import type { StructuralRequest } from "./structural-plan.js";
|
||||||
|
import { componentEntries, nixLiteral } from "./component-nix.js";
|
||||||
|
|
||||||
|
/** Add one ordinary component entry without regenerating existing application code. */
|
||||||
|
export async function scaffoldComponent(
|
||||||
|
root: string,
|
||||||
|
prefix: string,
|
||||||
|
spec: ScaffoldRecipe,
|
||||||
|
): Promise<StructuralRequest> {
|
||||||
|
if (!spec.declaration || !spec.name || !/^[A-Z][A-Za-z0-9_]*$/.test(spec.name) || !spec.id)
|
||||||
|
throw Error("Component scaffold requires a PascalCase name, ID and QX declaration");
|
||||||
|
const source = `package Draft id "draft" revision "draft@1" { ${spec.declaration} }`;
|
||||||
|
const syntax = parseQx(source);
|
||||||
|
const declarations = [...walkSyntax(syntax.root)].filter((node) => node.kind === "moduleExport");
|
||||||
|
const pkg = syntax.root.children.find((node) => node.kind === "packageResourceDecl");
|
||||||
|
if (
|
||||||
|
syntax.diagnostics.length ||
|
||||||
|
syntax.root.children.length !== 1 ||
|
||||||
|
declarations.length !== 1 ||
|
||||||
|
pkg?.children.filter((node) => node.kind === "packageExport").length !== 1
|
||||||
|
)
|
||||||
|
throw Error("Expected exactly one component declaration");
|
||||||
|
const declaration = declarations[0]!;
|
||||||
|
const identifier = declaration.children.find((node) => node.kind === "identifier");
|
||||||
|
const id = declaration.children.find((node) => node.kind === "stringLiteral");
|
||||||
|
if (
|
||||||
|
!identifier ||
|
||||||
|
source.slice(identifier.start, identifier.end) !== spec.name ||
|
||||||
|
!id ||
|
||||||
|
JSON.parse(source.slice(id.start, id.end)) !== spec.id
|
||||||
|
)
|
||||||
|
throw Error("Component declaration name/ID must match its registration");
|
||||||
|
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
|
||||||
|
const existing = parseQx(authored);
|
||||||
|
const parent = existing.root.children.find((node) => node.kind === "packageResourceDecl");
|
||||||
|
if (existing.diagnostics.length || !parent) throw Error("Fix package.qx before adding a component");
|
||||||
|
for (const item of parent.children.filter((node) => node.kind === "packageExport").flatMap((node) => node.children)) {
|
||||||
|
const name = item.children.find((node) => node.kind === "identifier");
|
||||||
|
const literal = item.children.find((node) => node.kind === "stringLiteral");
|
||||||
|
if (
|
||||||
|
(name && authored.slice(name.start, name.end) === spec.name) ||
|
||||||
|
(literal && JSON.parse(authored.slice(literal.start, literal.end)) === spec.id)
|
||||||
|
)
|
||||||
|
throw Error("Component requires a unique name and ID");
|
||||||
|
}
|
||||||
|
const filename = "components.nix";
|
||||||
|
const before = await fs.readFile(path.join(root, prefix, filename), "utf8");
|
||||||
|
const entries = await componentEntries(before);
|
||||||
|
if (!entries || typeof entries !== "object" || Array.isArray(entries) || Object.hasOwn(entries, spec.id))
|
||||||
|
throw Error("Invalid or duplicate component entry");
|
||||||
|
const insertion = ` ${nixLiteral(spec.id)} = { entry = ${nixLiteral(`src/components/${spec.name}.tsx`)}; export = ${nixLiteral(spec.name)}; };\n`;
|
||||||
|
const after = before.replace(/\n}(\s*)$/, (_match, trailing) => "\n" + insertion + "}" + trailing);
|
||||||
|
const parentID = parent.children.find((node) => node.kind === "stringLiteral")!;
|
||||||
|
const configFile = prefix + "quixos.check.json";
|
||||||
|
const configBefore = await fs.readFile(path.join(root, configFile), "utf8");
|
||||||
|
const config = JSON.parse(configBefore);
|
||||||
|
config.queryFrontends = [...new Set([...(config.queryFrontends ?? []), "react"])];
|
||||||
|
const preparationFile = prefix + "quixos.react.json";
|
||||||
|
let preparationBefore: string | undefined;
|
||||||
|
try {
|
||||||
|
preparationBefore = await fs.readFile(path.join(root, preparationFile), "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
const preparation = preparationBefore ? JSON.parse(preparationBefore) : { modules: {} };
|
||||||
|
if (Object.hasOwn(preparation.modules, spec.name)) throw Error("Duplicate presentation preparation");
|
||||||
|
preparation.modules[spec.name] = { inputs: [] };
|
||||||
|
const preparationText = JSON.stringify(preparation, null, 2) + "\n";
|
||||||
|
return {
|
||||||
|
kind: "package",
|
||||||
|
source: spec.source,
|
||||||
|
resourceRoot: spec.directory,
|
||||||
|
validation: "syntax",
|
||||||
|
files: [
|
||||||
|
preparationBefore === undefined
|
||||||
|
? { file: preparationFile, create: preparationText }
|
||||||
|
: { file: preparationFile, expected: preparationBefore, replace: preparationText },
|
||||||
|
{ file: configFile, expected: configBefore, replace: JSON.stringify(config, null, 2) + "\n" },
|
||||||
|
{
|
||||||
|
file: prefix + "package.qx",
|
||||||
|
edits: [
|
||||||
|
{
|
||||||
|
operation: "append",
|
||||||
|
parent: { kind: "packageResourceDecl", id: JSON.parse(authored.slice(parentID.start, parentID.end)) },
|
||||||
|
source: spec.declaration,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ file: prefix + filename, expected: before, replace: after },
|
||||||
|
{
|
||||||
|
file: prefix + `src/components/${spec.name}.tsx`,
|
||||||
|
create: `import type { Modules } from "../gen/client.js";\n\n// Declare initial query inputs in quixos.react.json; use interfaces for actions.\nexport const ${spec.name}: Modules[${JSON.stringify(spec.name)}] = ({subject}) => <p>{subject.object}</p>;\n`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { ScaffoldRecipe } from "./scaffold-recipes.js";
|
||||||
|
import type { StructuralRequest } from "./structural-plan.js";
|
||||||
|
export async function scaffoldController(
|
||||||
|
root: string,
|
||||||
|
prefix: string,
|
||||||
|
spec: ScaffoldRecipe,
|
||||||
|
): Promise<StructuralRequest> {
|
||||||
|
const entry = { id: spec.id, ...spec.controller };
|
||||||
|
const fields = ["id", "subject", "snapshot", "remote", "input"];
|
||||||
|
if (
|
||||||
|
Object.keys(entry).some((key) => ![...fields, "registry"].includes(key)) ||
|
||||||
|
(entry.registry !== undefined && typeof entry.registry !== "string") ||
|
||||||
|
fields.some((key) => typeof (entry as Record<string, unknown>)[key] !== "string") ||
|
||||||
|
!["unit", "value"].includes(entry.input ?? "")
|
||||||
|
)
|
||||||
|
throw Error("Controller requires id, subject, snapshot, remote and input (unit/value)");
|
||||||
|
const file = prefix + "quixos.controllers.json";
|
||||||
|
let before: string | undefined;
|
||||||
|
try {
|
||||||
|
before = await fs.readFile(path.join(root, file), "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
const catalog = before ? JSON.parse(before) : { schemaVersion: 1, controllers: [] };
|
||||||
|
if (
|
||||||
|
catalog.schemaVersion !== 1 ||
|
||||||
|
!Array.isArray(catalog.controllers) ||
|
||||||
|
catalog.controllers.length >= 1000 ||
|
||||||
|
catalog.controllers.some((c: { id: string }) => c.id === entry.id)
|
||||||
|
)
|
||||||
|
throw Error("Invalid catalog or duplicate controller ID");
|
||||||
|
catalog.controllers.push(entry);
|
||||||
|
const content = JSON.stringify(catalog, null, 2) + "\n";
|
||||||
|
return {
|
||||||
|
kind: "package",
|
||||||
|
source: spec.source,
|
||||||
|
resourceRoot: spec.directory,
|
||||||
|
validation: "syntax",
|
||||||
|
files: [before === undefined ? { file, create: content } : { file, expected: before, replace: content }],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdir, mkdtemp, lstat, rename, rm, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { materializeWorkspaceSources, workspaceSourcePin, type WorkspaceSourcePin } from "./source-closure.js";
|
||||||
|
|
||||||
|
const exec = promisify(callback);
|
||||||
|
|
||||||
|
/** Developer workflow, not an ordinary Workspace Agent's implicit permission
|
||||||
|
* to publish against canonical repositories. Creates independent Git/jj copies,
|
||||||
|
* with instance binding outside committed source. No Graph is started here.
|
||||||
|
*/
|
||||||
|
export async function prepareDevelopmentSources(options: {
|
||||||
|
source: WorkspaceSourcePin;
|
||||||
|
directory: string;
|
||||||
|
cacheDirectory: string;
|
||||||
|
locations?: Record<string, string>;
|
||||||
|
workspaceId?: string;
|
||||||
|
}) {
|
||||||
|
const source = workspaceSourcePin(options.source);
|
||||||
|
if (options.workspaceId !== undefined && !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/.test(options.workspaceId))
|
||||||
|
throw Error("Development source binding requires a Workspace UUID");
|
||||||
|
const destination = path.resolve(options.directory);
|
||||||
|
const cache = path.resolve(options.cacheDirectory);
|
||||||
|
if (cache === destination || cache.startsWith(destination + path.sep))
|
||||||
|
throw Error("The retained source cache must live outside the disposable workbench");
|
||||||
|
if (
|
||||||
|
await lstat(destination).catch((error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
throw Error("Choose a new development workbench directory; existing checkouts are never overwritten");
|
||||||
|
const closure = await materializeWorkspaceSources({
|
||||||
|
source,
|
||||||
|
directory: options.cacheDirectory,
|
||||||
|
locations: options.locations,
|
||||||
|
});
|
||||||
|
const origins = new Set<string>();
|
||||||
|
for (const entry of closure.resources) {
|
||||||
|
const identity = `${entry.kind}:${entry.repository}`;
|
||||||
|
if (origins.has(identity)) throw Error("Multiple revisions of one repository require explicit editable selection");
|
||||||
|
origins.add(identity);
|
||||||
|
}
|
||||||
|
const parent = path.dirname(destination);
|
||||||
|
await mkdir(parent, { recursive: true });
|
||||||
|
const staging = await mkdtemp(path.join(parent, ".development-sources-"));
|
||||||
|
// This local association does not create or grant access to a hosted Graph.
|
||||||
|
const workspaceId = options.workspaceId ?? randomUUID();
|
||||||
|
try {
|
||||||
|
const resources = [];
|
||||||
|
for (const entry of closure.resources) {
|
||||||
|
const relative = entry.kind === "workspace" ? "root" : "resources/" + entry.directory;
|
||||||
|
const checkout = path.join(staging, relative);
|
||||||
|
await mkdir(path.dirname(checkout), { recursive: true });
|
||||||
|
await exec("git", [
|
||||||
|
"clone",
|
||||||
|
"--no-hardlinks",
|
||||||
|
"--",
|
||||||
|
path.join(path.resolve(options.cacheDirectory), entry.directory),
|
||||||
|
checkout,
|
||||||
|
]);
|
||||||
|
await exec("git", ["remote", "set-url", "origin", entry.repository], { cwd: checkout });
|
||||||
|
await exec("git", ["checkout", "--detach", entry.commit], { cwd: checkout });
|
||||||
|
await exec("jj", ["git", "init", "--colocate"], {
|
||||||
|
cwd: checkout,
|
||||||
|
env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0" },
|
||||||
|
});
|
||||||
|
await exec("jj", ["describe", "-m", "Develop canonical application source"], {
|
||||||
|
cwd: checkout,
|
||||||
|
env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0" },
|
||||||
|
});
|
||||||
|
if (entry.kind !== "workspace")
|
||||||
|
resources.push({
|
||||||
|
kind: entry.kind,
|
||||||
|
directory: relative,
|
||||||
|
source: { resolver: "git", repository: entry.repository, commit: entry.commit },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await mkdir(path.join(staging, ".quixos"), { mode: 0o700 });
|
||||||
|
await writeFile(
|
||||||
|
path.join(staging, ".quixos/resource-graph.json"),
|
||||||
|
JSON.stringify({ formatVersion: 1, workspaceId, resources }, null, 2) + "\n",
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(staging, ".quixos/development-source.json"),
|
||||||
|
JSON.stringify({ source, workspaceId }, null, 2) + "\n",
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
await rename(staging, destination);
|
||||||
|
return { directory: destination, source, workspaceId, repositories: closure.resources.length };
|
||||||
|
} finally {
|
||||||
|
await rm(staging, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { ScaffoldRecipe } from "./scaffold-recipes.js";
|
||||||
|
import type { StructuralRequest } from "./structural-plan.js";
|
||||||
|
import { parseQx, walkSyntax } from "./source.js";
|
||||||
|
import { addImplementation } from "./implementation-edit.js";
|
||||||
|
import { formatQuixosLock } from "../resource-lock/index.js";
|
||||||
|
import { javascriptTarget } from "../execution-world/native-backends.js";
|
||||||
|
const json = (value: unknown) => JSON.stringify(value, null, 2) + "\n";
|
||||||
|
const q = (value: string) => JSON.stringify(value);
|
||||||
|
const nixString = (value: string) => q(value).replaceAll("${", "\\${");
|
||||||
|
export async function isExecutionScaffold(root: string, prefix: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await fs.readFile(path.join(root, prefix, "quixos.check.json"), "utf8")).backend === "execution";
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Insert ordinary authored code; declarations remain the only export inventory. */
|
||||||
|
export async function executionScaffold(
|
||||||
|
root: string,
|
||||||
|
command: string,
|
||||||
|
spec: ScaffoldRecipe,
|
||||||
|
prefix: string,
|
||||||
|
nixSource: (source: NonNullable<ScaffoldRecipe["tools"]>["helpers"]) => string,
|
||||||
|
): Promise<StructuralRequest> {
|
||||||
|
if (!spec.name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || !spec.id)
|
||||||
|
throw new Error("Execution scaffold requires a simple name and an ID");
|
||||||
|
if (spec.initialFiles && command !== "package") throw new Error("Initial files are only for new packages");
|
||||||
|
const files: StructuralRequest["files"] = [];
|
||||||
|
const create = (file: string, content: string) => files.push({ file: prefix + file, create: content });
|
||||||
|
if (command === "package") {
|
||||||
|
if (!spec.tools || !spec.revision) throw new Error("Execution package requires exact tool sources and a revision");
|
||||||
|
create(
|
||||||
|
"package.qx",
|
||||||
|
`symbol JavaScript id ${q(javascriptTarget)};\npackage ${spec.name} id ${q(spec.id)} revision ${q(spec.revision)} {\n}\n`,
|
||||||
|
);
|
||||||
|
create(
|
||||||
|
"quixos.lock",
|
||||||
|
formatQuixosLock({ formatVersion: 1, quixos: { resolver: "git", ...spec.tools.quixos }, resources: [] }),
|
||||||
|
);
|
||||||
|
create("quixos.check.json", json({ backend: "execution" }));
|
||||||
|
create("components.nix", "{\n}\n");
|
||||||
|
create(
|
||||||
|
"package.json",
|
||||||
|
json({
|
||||||
|
name: `@quixos/${spec.name.toLowerCase()}`,
|
||||||
|
private: true,
|
||||||
|
type: "module",
|
||||||
|
packageManager: "yarn@4.18.0",
|
||||||
|
scripts: { typecheck: "qx-workspace check" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
create(
|
||||||
|
".gitignore",
|
||||||
|
"node_modules/\ndist/\nsrc/gen/\n.quixos/\nresult\n.yarn/cache/\n.yarn/install-state.gz\n.yarn/unplugged/\n",
|
||||||
|
);
|
||||||
|
create(
|
||||||
|
".yarnrc.yml",
|
||||||
|
'enableScripts: true\n\ngenerateDefaultNix: false\n\nindividualNixPackaging: true\n\nnodeLinker: node-modules\n\nsupportedArchitectures:\n os:\n - current\n - linux\n cpu:\n - current\n - x64\n - arm64\n libc:\n - current\n - glibc\n\nplugins:\n - checksum: 271de9a785321a37c564b8e2a6b5cd689850b20572d5fee8ec0a6b8e7a5d4cfb59e533524af2b4a537e00da12828abd062eaec6978556e3dd9149a020db2591b\n path: .yarn/plugins/yarn-plugin-nixify.cjs\n spec: "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/yarn-plugin-nixify-patched/raw/commit/4528fdd20b30d869262443b3f044549810e75fb8/dist/yarn-plugin-nixify.js"\n',
|
||||||
|
);
|
||||||
|
for (const lane of ["browser", "server", "remote"])
|
||||||
|
create(
|
||||||
|
`src/${lane}.ts`,
|
||||||
|
`import { createRegistry } from "./gen/${lane === "remote" ? "remote" : "qx"}.js";\nexport const registry = createRegistry({});\n`,
|
||||||
|
);
|
||||||
|
create(
|
||||||
|
"flake.nix",
|
||||||
|
`{
|
||||||
|
inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))};
|
||||||
|
inputs.nixpkgs.follows = "protocol/nixpkgs";
|
||||||
|
inputs.flake-utils.follows = "protocol/flake-utils";
|
||||||
|
inputs.helpers = { url = ${nixString(nixSource(spec.tools.helpers))}; flake = false; };
|
||||||
|
outputs = { nixpkgs, flake-utils, helpers, ... }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system: let
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
camino = import (helpers + "/quixos-package-helpers.nix");
|
||||||
|
in {
|
||||||
|
devShells.default = (import (helpers + "/quixos-package-helpers.nix")).mkCaminoYarnShell { inherit pkgs; };
|
||||||
|
quixosPackages.checkedArtifacts = context:
|
||||||
|
let
|
||||||
|
pkgs = camino.packageSet { inherit nixpkgs system context; };
|
||||||
|
contract = builtins.fromJSON (builtins.readFile context.buildContract);
|
||||||
|
package = contract.definition.package;
|
||||||
|
portable = builtins.filter (entry: entry.execution.kind != "remote") package.exports;
|
||||||
|
remote = builtins.filter (entry: entry.execution.kind == "remote") package.exports;
|
||||||
|
modules = import ./components.nix;
|
||||||
|
dependencies = if builtins.pathExists ./yarn-project.nix
|
||||||
|
then camino.mkCaminoYarnDependencies { inherit pkgs; src = ./.; } else null;
|
||||||
|
in camino.assembleCaminoPackage {
|
||||||
|
inherit pkgs context;
|
||||||
|
artifacts =
|
||||||
|
pkgs.lib.optional (portable != []) (camino.mkCaminoTypeScriptPackage {
|
||||||
|
inherit pkgs context dependencies; src = ./.;
|
||||||
|
entry = "src/server.ts"; entries.browser = "src/browser.ts";
|
||||||
|
targets = pkgs.lib.unique (map (target: if target == "target:org.quixos.execution.javascript@1" then "browser" else if target == "server" then "server" else throw "Unsupported TypeScript backend target") (pkgs.lib.concatMap (entry: entry.execution.targets) portable));
|
||||||
|
}) ++ pkgs.lib.optional (remote != []) (camino.mkCaminoTypeScriptService {
|
||||||
|
inherit pkgs context dependencies; src = ./.; entry = "src/remote.ts";
|
||||||
|
}) ++ pkgs.lib.optional (modules != {}) (camino.mkCaminoReactPackage {
|
||||||
|
inherit pkgs context dependencies modules; src = ./.;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
create(
|
||||||
|
"README.md",
|
||||||
|
`# ${spec.name}\n\nThis execution package exports checked browser/server modules and a process service.\nUse scaffold function with an explicit execution contract to add a typed handler.\nSnapshot/transaction targets choose browser.ts and/or server.ts; remote handlers\nuse remote.ts. Shared helpers are ordinary imports. Generated bindings are build\noutputs, not authored source. The Nix composition supplies the canonical package contract\nand explicit runtime dependencies to checkedArtifacts.\n\nRun qx-workspace check from this package to build it independently, or from a\nworkspace to also check installed bindings. Activate the resulting report separately through the workspace\nhost. A source-only structural check is not runtime qualification.\n`,
|
||||||
|
);
|
||||||
|
} else if (command === "function") {
|
||||||
|
if (!spec.declaration)
|
||||||
|
throw new Error("Execution functions require a declaration with explicit execution effect and targets");
|
||||||
|
const wrapped = `package Scaffold id "scaffold" revision "scaffold@1" { ${spec.declaration} }`;
|
||||||
|
const parsed = parseQx(wrapped);
|
||||||
|
const pkg = parsed.root.children.find((node) => node.kind === "packageResourceDecl");
|
||||||
|
const exports = [...walkSyntax(parsed.root)].filter((node) =>
|
||||||
|
["packageFunctionExport", "packageOperationExport"].includes(node.kind),
|
||||||
|
);
|
||||||
|
const declaration = exports[0];
|
||||||
|
if (
|
||||||
|
parsed.diagnostics.length ||
|
||||||
|
exports.length !== 1 ||
|
||||||
|
!declaration ||
|
||||||
|
parsed.root.children.length !== 1 ||
|
||||||
|
pkg?.children.filter((node) => node.kind === "packageExport").length !== 1 ||
|
||||||
|
pkg?.children.some((node) => !["identifier", "stringLiteral", "packageExport"].includes(node.kind))
|
||||||
|
)
|
||||||
|
throw new Error("Expected one function or operation export with an execution contract");
|
||||||
|
const text = (node: { start: number; end: number }) => wrapped.slice(node.start, node.end);
|
||||||
|
const name = declaration.children.find((node) => node.kind === "identifier");
|
||||||
|
const id = declaration.children.find((node) => node.kind === "stringLiteral");
|
||||||
|
const execution = declaration.children.find((node) => node.kind === "executionClause");
|
||||||
|
if (!name || text(name) !== spec.name || !id || JSON.parse(text(id)) !== spec.id || !execution)
|
||||||
|
throw new Error("Declaration name/ID must match; an explicit execution contract is required");
|
||||||
|
const tokens = parsed.tokens
|
||||||
|
.filter((token) => !token.trivia && token.start >= execution.start && token.end <= execution.end)
|
||||||
|
.map((token) => token.text);
|
||||||
|
const remote = tokens[1] === "remote";
|
||||||
|
const declaredTargets = remote
|
||||||
|
? ["remote"]
|
||||||
|
: [...walkSyntax(execution)].filter((node) => node.kind === "identifier").map(text);
|
||||||
|
const targets = declaredTargets.length ? declaredTargets : ["server"];
|
||||||
|
if (
|
||||||
|
!targets.length ||
|
||||||
|
new Set(targets).size !== targets.length ||
|
||||||
|
targets.some((target) => !(remote ? ["remote"] : ["JavaScript", "server"]).includes(target))
|
||||||
|
)
|
||||||
|
throw new Error("This TypeScript scaffold supports the JavaScript target, server, and remote service handlers");
|
||||||
|
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
|
||||||
|
const syntax = parseQx(authored);
|
||||||
|
const packageNode = syntax.root.children.find((node) => node.kind === "packageResourceDecl");
|
||||||
|
if (syntax.diagnostics.length || !packageNode) throw new Error("Fix package.qx before adding a handler");
|
||||||
|
for (const node of [...walkSyntax(packageNode)].filter((node) =>
|
||||||
|
["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind),
|
||||||
|
)) {
|
||||||
|
const identifier = node.children.find((child) => child.kind === "identifier");
|
||||||
|
const literal = node.children.find((child) => child.kind === "stringLiteral");
|
||||||
|
if (
|
||||||
|
(identifier && authored.slice(identifier.start, identifier.end) === spec.name) ||
|
||||||
|
(literal && JSON.parse(authored.slice(literal.start, literal.end)) === spec.id)
|
||||||
|
)
|
||||||
|
throw new Error("New export requires a unique name and ID");
|
||||||
|
}
|
||||||
|
const packageID = packageNode.children.find((node) => node.kind === "stringLiteral")!;
|
||||||
|
files.push({
|
||||||
|
file: prefix + "package.qx",
|
||||||
|
edits: [
|
||||||
|
{
|
||||||
|
operation: "append",
|
||||||
|
parent: { kind: "packageResourceDecl", id: JSON.parse(authored.slice(packageID.start, packageID.end)) },
|
||||||
|
source: spec.declaration,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
create(
|
||||||
|
`src/impl/${spec.name}.ts`,
|
||||||
|
`import type { ${remote ? "Remote" : "Portable"}Implementation } from "../gen/${remote ? "remote" : "qx"}.js";\nexport const handler: ${remote ? "Remote" : "Portable"}Implementation[${q(spec.name)}] = async (_context) => { throw new Error(${q("Implement " + spec.name)}); };\n`,
|
||||||
|
);
|
||||||
|
for (const target of targets) {
|
||||||
|
const file = prefix + `src/${target === "JavaScript" ? "browser" : target}.ts`;
|
||||||
|
const before = await fs.readFile(path.join(root, file), "utf8");
|
||||||
|
files.push({
|
||||||
|
file,
|
||||||
|
expected: before,
|
||||||
|
replace: addImplementation(before, "createRegistry", spec.name, `./impl/${spec.name}.js`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
throw new Error("Execution packages use ordinary factory transactions; migration scaffolding is not supported");
|
||||||
|
if (spec.initialFiles) {
|
||||||
|
for (const [file, content] of Object.entries(spec.initialFiles)) {
|
||||||
|
if (
|
||||||
|
typeof content !== "string" ||
|
||||||
|
!/^(?:package\.qx|(?:package|components|flake|yarn-project)\.nix|package\.json|yarn\.lock|\.yarnrc\.yml|\.gitignore|\.yarn\/plugins\/[A-Za-z0-9_.-]+\.cjs|quixos\.controllers\.json|README\.md|(?:src|queries)\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_.-]+)$/.test(
|
||||||
|
file,
|
||||||
|
) ||
|
||||||
|
file.split("/").some((part) => part === ".." || part === "." || part === "gen")
|
||||||
|
)
|
||||||
|
throw new Error("Not an initial authored execution file: " + file);
|
||||||
|
const previous = files.findIndex((entry) => entry.file === prefix + file);
|
||||||
|
if (previous >= 0) files.splice(previous, 1);
|
||||||
|
create(file, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files };
|
||||||
|
}
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
export class FileLockBusyError extends Error {}
|
||||||
|
|
||||||
/** Kernel-owned lock: a crashed coordinator cannot leave a stale ownership file.
|
/** Kernel-owned lock: a crashed coordinator cannot leave a stale ownership file.
|
||||||
* The persistent file is just an inode; EOF releases the helper's lock. */
|
* The persistent file is just an inode; EOF releases the helper's lock. */
|
||||||
export async function withFileLock<T>(filename: string, work: () => Promise<T>): Promise<T> {
|
export async function withFileLock<T>(
|
||||||
|
filename: string,
|
||||||
|
work: () => Promise<T>,
|
||||||
|
{ nonblocking = false }: { nonblocking?: boolean } = {},
|
||||||
|
): Promise<T> {
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
"flock",
|
"flock",
|
||||||
[
|
[
|
||||||
"--exclusive",
|
"--exclusive",
|
||||||
"--timeout",
|
...(nonblocking ? ["--nonblock"] : ["--timeout", "120"]),
|
||||||
"120",
|
|
||||||
"--conflict-exit-code",
|
"--conflict-exit-code",
|
||||||
"75",
|
"75",
|
||||||
filename,
|
filename,
|
||||||
@@ -32,15 +37,17 @@ export async function withFileLock<T>(filename: string, work: () => Promise<T>):
|
|||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
let output = "";
|
let output = "";
|
||||||
child.once("error", reject);
|
child.once("error", reject);
|
||||||
child.once("exit", (code) =>
|
child.once("exit", (code) => {
|
||||||
reject(
|
reject(
|
||||||
new Error(
|
|
||||||
code === 75
|
code === 75
|
||||||
? "Timed out after 120 seconds waiting for another authoring command; inspect that command before retrying"
|
? new FileLockBusyError(
|
||||||
: `Cannot acquire authoring lock: ${diagnostics}`,
|
nonblocking
|
||||||
),
|
? "Another process owns this lock"
|
||||||
),
|
: "Timed out after 120 seconds waiting for another authoring command; inspect that command before retrying",
|
||||||
|
)
|
||||||
|
: new Error(`Cannot acquire authoring lock: ${diagnostics}`),
|
||||||
);
|
);
|
||||||
|
});
|
||||||
child.stdout.on("data", (chunk) => {
|
child.stdout.on("data", (chunk) => {
|
||||||
output += chunk;
|
output += chunk;
|
||||||
if (output.includes("locked\n")) resolve();
|
if (output.includes("locked\n")) resolve();
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,229 +1,311 @@
|
|||||||
WORKSPACE=1
|
EDIT=1
|
||||||
TYPE=2
|
MODULE=2
|
||||||
OBJECT=3
|
CONTRACT=3
|
||||||
STORABLE=4
|
PRESENTATION=4
|
||||||
IMPLEMENTS=5
|
SUBJECT=5
|
||||||
REF=6
|
FOR=6
|
||||||
FRAGMENT=7
|
CREATION=7
|
||||||
IMPORT=8
|
VARIANT=8
|
||||||
EXTERNAL=9
|
PREPARES=9
|
||||||
ATOM=10
|
INITIALIZE=10
|
||||||
INTERFACE=11
|
WORKSPACE=11
|
||||||
INTERFACES=12
|
EXECUTION=12
|
||||||
PACKAGE=13
|
SNAPSHOT=13
|
||||||
VALUE=14
|
TRANSACTION=14
|
||||||
RELATION=15
|
REMOTE=15
|
||||||
OPERATION=16
|
TARGETS=16
|
||||||
FUNCTION=17
|
PORTABLE=17
|
||||||
CONSTRUCTOR=18
|
REPLICA=18
|
||||||
CONSTRUCTS=19
|
LOCAL=19
|
||||||
INPUT=20
|
FROM=20
|
||||||
CONFORM=21
|
RESULT_BYTES=21
|
||||||
AS=22
|
RPC_CALLS=22
|
||||||
BIND=23
|
DEADLINE_MS=23
|
||||||
STATIC=24
|
QUERY=24
|
||||||
TO=25
|
REFLECTION=25
|
||||||
PRIVATE=26
|
SPECIALIZE=26
|
||||||
SHARED=27
|
ROOT=27
|
||||||
STATE=28
|
DOCUMENT=28
|
||||||
EDGE=29
|
FRAGMENTS=29
|
||||||
PROJECTION=30
|
VIEW=30
|
||||||
WITH=31
|
MAX=31
|
||||||
USING=32
|
ALLOW=32
|
||||||
VIA=33
|
POLL=33
|
||||||
MATERIALIZE=34
|
QUERYABLE=34
|
||||||
IF=35
|
RPC=35
|
||||||
ABSENT=36
|
QUERY_REASON=36
|
||||||
ON=37
|
TYPE=37
|
||||||
POLICY=38
|
OBJECT=38
|
||||||
DEFAULT=39
|
STORABLE=39
|
||||||
SOURCE=40
|
IMPLEMENTS=40
|
||||||
REPOSITORY=41
|
REF=41
|
||||||
COMMIT=42
|
FRAGMENT=42
|
||||||
REVISION=43
|
IMPORT=43
|
||||||
SEMANTIC_MAJOR=44
|
EXTERNAL=44
|
||||||
ON_DELETE=45
|
SYMBOL=45
|
||||||
RETAIN_OTHER=46
|
ATOM=46
|
||||||
KEYED=47
|
INTERFACE=47
|
||||||
PUBLIC_TRAVERSAL=48
|
INTERFACES=48
|
||||||
ID=49
|
PACKAGE=49
|
||||||
DOC=50
|
VALUE=50
|
||||||
MODE=51
|
FIELD=51
|
||||||
EMITS=52
|
RELATION=52
|
||||||
RECEIVER=53
|
OPERATION=53
|
||||||
REQUIRES=54
|
FUNCTION=54
|
||||||
ANY=55
|
CONSTRUCTOR=55
|
||||||
GET=56
|
CONSTRUCTS=56
|
||||||
SET=57
|
INPUT=57
|
||||||
WATCH=58
|
CONFORM=58
|
||||||
START=59
|
AS=59
|
||||||
STOP=60
|
BIND=60
|
||||||
READ=61
|
STATIC=61
|
||||||
WRITE=62
|
TO=62
|
||||||
RESOLVE=63
|
PRIVATE=63
|
||||||
CONNECT=64
|
SHARED=64
|
||||||
DISCONNECT=65
|
STATE=65
|
||||||
CALL=66
|
EDGE=66
|
||||||
WATCH_START=67
|
PROJECTION=67
|
||||||
WATCH_STOP=68
|
WITH=68
|
||||||
SUBSCRIBE=69
|
USING=69
|
||||||
UNSUBSCRIBE=70
|
ACCEPT=70
|
||||||
OPTIMISTIC_REGISTER=71
|
VIA=71
|
||||||
CRDT=72
|
MATERIALIZE=72
|
||||||
OPTIONAL_ONE=73
|
IF=73
|
||||||
EXACTLY_ONE=74
|
ABSENT=74
|
||||||
MANY_UNIQUE=75
|
ON=75
|
||||||
MANY=76
|
POLICY=76
|
||||||
ORDERED=77
|
DEFAULT=77
|
||||||
UNIT=78
|
SOURCE=78
|
||||||
WATCH_HANDLE=79
|
REPOSITORY=79
|
||||||
MESSAGE=80
|
COMMIT=80
|
||||||
ATOM_REF=81
|
REVISION=81
|
||||||
INTERFACE_REF=82
|
SEMANTIC_MAJOR=82
|
||||||
OPTIONAL=83
|
ON_DELETE=83
|
||||||
LIST=84
|
LIFECYCLE=84
|
||||||
RECORD=85
|
JOURNAL=85
|
||||||
BOOL=86
|
SUBSCRIPTION=86
|
||||||
BYTES=87
|
RETAIN_OTHER=87
|
||||||
DOUBLE=88
|
KEYED=88
|
||||||
INT32=89
|
PUBLIC_TRAVERSAL=89
|
||||||
INT64=90
|
ID=90
|
||||||
STRING=91
|
DOC=91
|
||||||
UINT32=92
|
MODE=92
|
||||||
UINT64=93
|
EMITS=93
|
||||||
TRUE=94
|
RECEIVER=94
|
||||||
FALSE=95
|
REQUIRES=95
|
||||||
NULL=96
|
ANY=96
|
||||||
ARROW=97
|
GET=97
|
||||||
COLON=98
|
SET=98
|
||||||
SEMI=99
|
WATCH=99
|
||||||
COMMA=100
|
START=100
|
||||||
DOT=101
|
STOP=101
|
||||||
LBRACE=102
|
READ=102
|
||||||
RBRACE=103
|
WRITE=103
|
||||||
LBRACK=104
|
RESOLVE=104
|
||||||
RBRACK=105
|
CONNECT=105
|
||||||
LPAREN=106
|
DISCONNECT=106
|
||||||
RPAREN=107
|
CALL=107
|
||||||
LT=108
|
WATCH_START=108
|
||||||
GT=109
|
WATCH_STOP=109
|
||||||
AMP=110
|
SUBSCRIBE=110
|
||||||
EQUAL=111
|
UNSUBSCRIBE=111
|
||||||
INTEGER=112
|
REGISTER=112
|
||||||
JSON_NUMBER=113
|
CRDT=113
|
||||||
IDENTIFIER=114
|
OPTIONAL_ONE=114
|
||||||
STRING_LITERAL=115
|
EXACTLY_ONE=115
|
||||||
LINE_COMMENT=116
|
MANY_UNIQUE=116
|
||||||
BLOCK_COMMENT=117
|
MANY=117
|
||||||
WS=118
|
ORDERED=118
|
||||||
'workspace'=1
|
UNIT=119
|
||||||
'type'=2
|
WATCH_HANDLE=120
|
||||||
'object'=3
|
MESSAGE=121
|
||||||
'storable'=4
|
ATOM_REF=122
|
||||||
'implements'=5
|
INTERFACE_REF=123
|
||||||
'ref'=6
|
OPTIONAL=124
|
||||||
'fragment'=7
|
LIST=125
|
||||||
'import'=8
|
RECORD=126
|
||||||
'external'=9
|
BOOL=127
|
||||||
'atom'=10
|
BYTES=128
|
||||||
'interface'=11
|
DOUBLE=129
|
||||||
'interfaces'=12
|
INT32=130
|
||||||
'package'=13
|
INT64=131
|
||||||
'value'=14
|
STRING=132
|
||||||
'relation'=15
|
UINT32=133
|
||||||
'operation'=16
|
UINT64=134
|
||||||
'function'=17
|
TRUE=135
|
||||||
'constructor'=18
|
FALSE=136
|
||||||
'constructs'=19
|
NULL=137
|
||||||
'input'=20
|
ARROW=138
|
||||||
'conform'=21
|
COLON=139
|
||||||
'as'=22
|
SEMI=140
|
||||||
'bind'=23
|
COMMA=141
|
||||||
'static'=24
|
DOT=142
|
||||||
'to'=25
|
LBRACE=143
|
||||||
'private'=26
|
RBRACE=144
|
||||||
'shared'=27
|
LBRACK=145
|
||||||
'state'=28
|
RBRACK=146
|
||||||
'edge'=29
|
LPAREN=147
|
||||||
'projection'=30
|
RPAREN=148
|
||||||
'with'=31
|
LT=149
|
||||||
'using'=32
|
GT=150
|
||||||
'via'=33
|
AMP=151
|
||||||
'materialize'=34
|
EQUAL=152
|
||||||
'if'=35
|
INTEGER=153
|
||||||
'absent'=36
|
JSON_NUMBER=154
|
||||||
'on'=37
|
IDENTIFIER=155
|
||||||
'policy'=38
|
STRING_LITERAL=156
|
||||||
'default'=39
|
LINE_COMMENT=157
|
||||||
'source'=40
|
BLOCK_COMMENT=158
|
||||||
'repository'=41
|
WS=159
|
||||||
'commit'=42
|
'edit'=1
|
||||||
'revision'=43
|
'module'=2
|
||||||
'semantic-major'=44
|
'contract'=3
|
||||||
'on-delete'=45
|
'presentation'=4
|
||||||
'retain-other'=46
|
'subject'=5
|
||||||
'keyed'=47
|
'for'=6
|
||||||
'public-traversal'=48
|
'creation'=7
|
||||||
'id'=49
|
'variant'=8
|
||||||
'doc'=50
|
'prepares'=9
|
||||||
'mode'=51
|
'initialize'=10
|
||||||
'emits'=52
|
'workspace'=11
|
||||||
'receiver'=53
|
'execution'=12
|
||||||
'requires'=54
|
'snapshot'=13
|
||||||
'any'=55
|
'transaction'=14
|
||||||
'get'=56
|
'remote'=15
|
||||||
'set'=57
|
'targets'=16
|
||||||
'watch'=58
|
'portable'=17
|
||||||
'start'=59
|
'replica'=18
|
||||||
'stop'=60
|
'local'=19
|
||||||
'read'=61
|
'from'=20
|
||||||
'write'=62
|
'result-bytes'=21
|
||||||
'resolve'=63
|
'rpc-calls'=22
|
||||||
'connect'=64
|
'deadline-ms'=23
|
||||||
'disconnect'=65
|
'query'=24
|
||||||
'call'=66
|
'reflection'=25
|
||||||
'watch-start'=67
|
'specialize'=26
|
||||||
'watch-stop'=68
|
'root'=27
|
||||||
'subscribe'=69
|
'document'=28
|
||||||
'unsubscribe'=70
|
'fragments'=29
|
||||||
'optimistic-register'=71
|
'view'=30
|
||||||
'crdt'=72
|
'max'=31
|
||||||
'optional-one'=73
|
'allow'=32
|
||||||
'exactly-one'=74
|
'poll'=33
|
||||||
'many-unique'=75
|
'queryable'=34
|
||||||
'many'=76
|
'rpc'=35
|
||||||
'ordered'=77
|
'query-reason'=36
|
||||||
'unit'=78
|
'type'=37
|
||||||
'watch-handle'=79
|
'object'=38
|
||||||
'message'=80
|
'storable'=39
|
||||||
'atom-ref'=81
|
'implements'=40
|
||||||
'interface-ref'=82
|
'ref'=41
|
||||||
'optional'=83
|
'fragment'=42
|
||||||
'list'=84
|
'import'=43
|
||||||
'record'=85
|
'external'=44
|
||||||
'bool'=86
|
'symbol'=45
|
||||||
'bytes'=87
|
'atom'=46
|
||||||
'double'=88
|
'interface'=47
|
||||||
'int32'=89
|
'interfaces'=48
|
||||||
'int64'=90
|
'package'=49
|
||||||
'string'=91
|
'value'=50
|
||||||
'uint32'=92
|
'field'=51
|
||||||
'uint64'=93
|
'relation'=52
|
||||||
'true'=94
|
'operation'=53
|
||||||
'false'=95
|
'function'=54
|
||||||
'null'=96
|
'constructor'=55
|
||||||
'->'=97
|
'constructs'=56
|
||||||
':'=98
|
'input'=57
|
||||||
';'=99
|
'conform'=58
|
||||||
','=100
|
'as'=59
|
||||||
'.'=101
|
'bind'=60
|
||||||
'{'=102
|
'static'=61
|
||||||
'}'=103
|
'to'=62
|
||||||
'['=104
|
'private'=63
|
||||||
']'=105
|
'shared'=64
|
||||||
'('=106
|
'state'=65
|
||||||
')'=107
|
'edge'=66
|
||||||
'<'=108
|
'projection'=67
|
||||||
'>'=109
|
'with'=68
|
||||||
'&'=110
|
'using'=69
|
||||||
'='=111
|
'accept'=70
|
||||||
|
'via'=71
|
||||||
|
'materialize'=72
|
||||||
|
'if'=73
|
||||||
|
'absent'=74
|
||||||
|
'on'=75
|
||||||
|
'policy'=76
|
||||||
|
'default'=77
|
||||||
|
'source'=78
|
||||||
|
'repository'=79
|
||||||
|
'commit'=80
|
||||||
|
'revision'=81
|
||||||
|
'semantic-major'=82
|
||||||
|
'on-delete'=83
|
||||||
|
'lifecycle'=84
|
||||||
|
'journal'=85
|
||||||
|
'subscription'=86
|
||||||
|
'retain-other'=87
|
||||||
|
'keyed'=88
|
||||||
|
'public-traversal'=89
|
||||||
|
'id'=90
|
||||||
|
'doc'=91
|
||||||
|
'mode'=92
|
||||||
|
'emits'=93
|
||||||
|
'receiver'=94
|
||||||
|
'requires'=95
|
||||||
|
'any'=96
|
||||||
|
'get'=97
|
||||||
|
'set'=98
|
||||||
|
'watch'=99
|
||||||
|
'start'=100
|
||||||
|
'stop'=101
|
||||||
|
'read'=102
|
||||||
|
'write'=103
|
||||||
|
'resolve'=104
|
||||||
|
'connect'=105
|
||||||
|
'disconnect'=106
|
||||||
|
'call'=107
|
||||||
|
'watch-start'=108
|
||||||
|
'watch-stop'=109
|
||||||
|
'subscribe'=110
|
||||||
|
'unsubscribe'=111
|
||||||
|
'register'=112
|
||||||
|
'crdt'=113
|
||||||
|
'optional-one'=114
|
||||||
|
'exactly-one'=115
|
||||||
|
'many-unique'=116
|
||||||
|
'many'=117
|
||||||
|
'ordered'=118
|
||||||
|
'unit'=119
|
||||||
|
'watch-handle'=120
|
||||||
|
'message'=121
|
||||||
|
'atom-ref'=122
|
||||||
|
'interface-ref'=123
|
||||||
|
'optional'=124
|
||||||
|
'list'=125
|
||||||
|
'record'=126
|
||||||
|
'bool'=127
|
||||||
|
'bytes'=128
|
||||||
|
'double'=129
|
||||||
|
'int32'=130
|
||||||
|
'int64'=131
|
||||||
|
'string'=132
|
||||||
|
'uint32'=133
|
||||||
|
'uint64'=134
|
||||||
|
'true'=135
|
||||||
|
'false'=136
|
||||||
|
'null'=137
|
||||||
|
'->'=138
|
||||||
|
':'=139
|
||||||
|
';'=140
|
||||||
|
','=141
|
||||||
|
'.'=142
|
||||||
|
'{'=143
|
||||||
|
'}'=144
|
||||||
|
'['=145
|
||||||
|
']'=146
|
||||||
|
'('=147
|
||||||
|
')'=148
|
||||||
|
'<'=149
|
||||||
|
'>'=150
|
||||||
|
'&'=151
|
||||||
|
'='=152
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,229 +1,311 @@
|
|||||||
WORKSPACE=1
|
EDIT=1
|
||||||
TYPE=2
|
MODULE=2
|
||||||
OBJECT=3
|
CONTRACT=3
|
||||||
STORABLE=4
|
PRESENTATION=4
|
||||||
IMPLEMENTS=5
|
SUBJECT=5
|
||||||
REF=6
|
FOR=6
|
||||||
FRAGMENT=7
|
CREATION=7
|
||||||
IMPORT=8
|
VARIANT=8
|
||||||
EXTERNAL=9
|
PREPARES=9
|
||||||
ATOM=10
|
INITIALIZE=10
|
||||||
INTERFACE=11
|
WORKSPACE=11
|
||||||
INTERFACES=12
|
EXECUTION=12
|
||||||
PACKAGE=13
|
SNAPSHOT=13
|
||||||
VALUE=14
|
TRANSACTION=14
|
||||||
RELATION=15
|
REMOTE=15
|
||||||
OPERATION=16
|
TARGETS=16
|
||||||
FUNCTION=17
|
PORTABLE=17
|
||||||
CONSTRUCTOR=18
|
REPLICA=18
|
||||||
CONSTRUCTS=19
|
LOCAL=19
|
||||||
INPUT=20
|
FROM=20
|
||||||
CONFORM=21
|
RESULT_BYTES=21
|
||||||
AS=22
|
RPC_CALLS=22
|
||||||
BIND=23
|
DEADLINE_MS=23
|
||||||
STATIC=24
|
QUERY=24
|
||||||
TO=25
|
REFLECTION=25
|
||||||
PRIVATE=26
|
SPECIALIZE=26
|
||||||
SHARED=27
|
ROOT=27
|
||||||
STATE=28
|
DOCUMENT=28
|
||||||
EDGE=29
|
FRAGMENTS=29
|
||||||
PROJECTION=30
|
VIEW=30
|
||||||
WITH=31
|
MAX=31
|
||||||
USING=32
|
ALLOW=32
|
||||||
VIA=33
|
POLL=33
|
||||||
MATERIALIZE=34
|
QUERYABLE=34
|
||||||
IF=35
|
RPC=35
|
||||||
ABSENT=36
|
QUERY_REASON=36
|
||||||
ON=37
|
TYPE=37
|
||||||
POLICY=38
|
OBJECT=38
|
||||||
DEFAULT=39
|
STORABLE=39
|
||||||
SOURCE=40
|
IMPLEMENTS=40
|
||||||
REPOSITORY=41
|
REF=41
|
||||||
COMMIT=42
|
FRAGMENT=42
|
||||||
REVISION=43
|
IMPORT=43
|
||||||
SEMANTIC_MAJOR=44
|
EXTERNAL=44
|
||||||
ON_DELETE=45
|
SYMBOL=45
|
||||||
RETAIN_OTHER=46
|
ATOM=46
|
||||||
KEYED=47
|
INTERFACE=47
|
||||||
PUBLIC_TRAVERSAL=48
|
INTERFACES=48
|
||||||
ID=49
|
PACKAGE=49
|
||||||
DOC=50
|
VALUE=50
|
||||||
MODE=51
|
FIELD=51
|
||||||
EMITS=52
|
RELATION=52
|
||||||
RECEIVER=53
|
OPERATION=53
|
||||||
REQUIRES=54
|
FUNCTION=54
|
||||||
ANY=55
|
CONSTRUCTOR=55
|
||||||
GET=56
|
CONSTRUCTS=56
|
||||||
SET=57
|
INPUT=57
|
||||||
WATCH=58
|
CONFORM=58
|
||||||
START=59
|
AS=59
|
||||||
STOP=60
|
BIND=60
|
||||||
READ=61
|
STATIC=61
|
||||||
WRITE=62
|
TO=62
|
||||||
RESOLVE=63
|
PRIVATE=63
|
||||||
CONNECT=64
|
SHARED=64
|
||||||
DISCONNECT=65
|
STATE=65
|
||||||
CALL=66
|
EDGE=66
|
||||||
WATCH_START=67
|
PROJECTION=67
|
||||||
WATCH_STOP=68
|
WITH=68
|
||||||
SUBSCRIBE=69
|
USING=69
|
||||||
UNSUBSCRIBE=70
|
ACCEPT=70
|
||||||
OPTIMISTIC_REGISTER=71
|
VIA=71
|
||||||
CRDT=72
|
MATERIALIZE=72
|
||||||
OPTIONAL_ONE=73
|
IF=73
|
||||||
EXACTLY_ONE=74
|
ABSENT=74
|
||||||
MANY_UNIQUE=75
|
ON=75
|
||||||
MANY=76
|
POLICY=76
|
||||||
ORDERED=77
|
DEFAULT=77
|
||||||
UNIT=78
|
SOURCE=78
|
||||||
WATCH_HANDLE=79
|
REPOSITORY=79
|
||||||
MESSAGE=80
|
COMMIT=80
|
||||||
ATOM_REF=81
|
REVISION=81
|
||||||
INTERFACE_REF=82
|
SEMANTIC_MAJOR=82
|
||||||
OPTIONAL=83
|
ON_DELETE=83
|
||||||
LIST=84
|
LIFECYCLE=84
|
||||||
RECORD=85
|
JOURNAL=85
|
||||||
BOOL=86
|
SUBSCRIPTION=86
|
||||||
BYTES=87
|
RETAIN_OTHER=87
|
||||||
DOUBLE=88
|
KEYED=88
|
||||||
INT32=89
|
PUBLIC_TRAVERSAL=89
|
||||||
INT64=90
|
ID=90
|
||||||
STRING=91
|
DOC=91
|
||||||
UINT32=92
|
MODE=92
|
||||||
UINT64=93
|
EMITS=93
|
||||||
TRUE=94
|
RECEIVER=94
|
||||||
FALSE=95
|
REQUIRES=95
|
||||||
NULL=96
|
ANY=96
|
||||||
ARROW=97
|
GET=97
|
||||||
COLON=98
|
SET=98
|
||||||
SEMI=99
|
WATCH=99
|
||||||
COMMA=100
|
START=100
|
||||||
DOT=101
|
STOP=101
|
||||||
LBRACE=102
|
READ=102
|
||||||
RBRACE=103
|
WRITE=103
|
||||||
LBRACK=104
|
RESOLVE=104
|
||||||
RBRACK=105
|
CONNECT=105
|
||||||
LPAREN=106
|
DISCONNECT=106
|
||||||
RPAREN=107
|
CALL=107
|
||||||
LT=108
|
WATCH_START=108
|
||||||
GT=109
|
WATCH_STOP=109
|
||||||
AMP=110
|
SUBSCRIBE=110
|
||||||
EQUAL=111
|
UNSUBSCRIBE=111
|
||||||
INTEGER=112
|
REGISTER=112
|
||||||
JSON_NUMBER=113
|
CRDT=113
|
||||||
IDENTIFIER=114
|
OPTIONAL_ONE=114
|
||||||
STRING_LITERAL=115
|
EXACTLY_ONE=115
|
||||||
LINE_COMMENT=116
|
MANY_UNIQUE=116
|
||||||
BLOCK_COMMENT=117
|
MANY=117
|
||||||
WS=118
|
ORDERED=118
|
||||||
'workspace'=1
|
UNIT=119
|
||||||
'type'=2
|
WATCH_HANDLE=120
|
||||||
'object'=3
|
MESSAGE=121
|
||||||
'storable'=4
|
ATOM_REF=122
|
||||||
'implements'=5
|
INTERFACE_REF=123
|
||||||
'ref'=6
|
OPTIONAL=124
|
||||||
'fragment'=7
|
LIST=125
|
||||||
'import'=8
|
RECORD=126
|
||||||
'external'=9
|
BOOL=127
|
||||||
'atom'=10
|
BYTES=128
|
||||||
'interface'=11
|
DOUBLE=129
|
||||||
'interfaces'=12
|
INT32=130
|
||||||
'package'=13
|
INT64=131
|
||||||
'value'=14
|
STRING=132
|
||||||
'relation'=15
|
UINT32=133
|
||||||
'operation'=16
|
UINT64=134
|
||||||
'function'=17
|
TRUE=135
|
||||||
'constructor'=18
|
FALSE=136
|
||||||
'constructs'=19
|
NULL=137
|
||||||
'input'=20
|
ARROW=138
|
||||||
'conform'=21
|
COLON=139
|
||||||
'as'=22
|
SEMI=140
|
||||||
'bind'=23
|
COMMA=141
|
||||||
'static'=24
|
DOT=142
|
||||||
'to'=25
|
LBRACE=143
|
||||||
'private'=26
|
RBRACE=144
|
||||||
'shared'=27
|
LBRACK=145
|
||||||
'state'=28
|
RBRACK=146
|
||||||
'edge'=29
|
LPAREN=147
|
||||||
'projection'=30
|
RPAREN=148
|
||||||
'with'=31
|
LT=149
|
||||||
'using'=32
|
GT=150
|
||||||
'via'=33
|
AMP=151
|
||||||
'materialize'=34
|
EQUAL=152
|
||||||
'if'=35
|
INTEGER=153
|
||||||
'absent'=36
|
JSON_NUMBER=154
|
||||||
'on'=37
|
IDENTIFIER=155
|
||||||
'policy'=38
|
STRING_LITERAL=156
|
||||||
'default'=39
|
LINE_COMMENT=157
|
||||||
'source'=40
|
BLOCK_COMMENT=158
|
||||||
'repository'=41
|
WS=159
|
||||||
'commit'=42
|
'edit'=1
|
||||||
'revision'=43
|
'module'=2
|
||||||
'semantic-major'=44
|
'contract'=3
|
||||||
'on-delete'=45
|
'presentation'=4
|
||||||
'retain-other'=46
|
'subject'=5
|
||||||
'keyed'=47
|
'for'=6
|
||||||
'public-traversal'=48
|
'creation'=7
|
||||||
'id'=49
|
'variant'=8
|
||||||
'doc'=50
|
'prepares'=9
|
||||||
'mode'=51
|
'initialize'=10
|
||||||
'emits'=52
|
'workspace'=11
|
||||||
'receiver'=53
|
'execution'=12
|
||||||
'requires'=54
|
'snapshot'=13
|
||||||
'any'=55
|
'transaction'=14
|
||||||
'get'=56
|
'remote'=15
|
||||||
'set'=57
|
'targets'=16
|
||||||
'watch'=58
|
'portable'=17
|
||||||
'start'=59
|
'replica'=18
|
||||||
'stop'=60
|
'local'=19
|
||||||
'read'=61
|
'from'=20
|
||||||
'write'=62
|
'result-bytes'=21
|
||||||
'resolve'=63
|
'rpc-calls'=22
|
||||||
'connect'=64
|
'deadline-ms'=23
|
||||||
'disconnect'=65
|
'query'=24
|
||||||
'call'=66
|
'reflection'=25
|
||||||
'watch-start'=67
|
'specialize'=26
|
||||||
'watch-stop'=68
|
'root'=27
|
||||||
'subscribe'=69
|
'document'=28
|
||||||
'unsubscribe'=70
|
'fragments'=29
|
||||||
'optimistic-register'=71
|
'view'=30
|
||||||
'crdt'=72
|
'max'=31
|
||||||
'optional-one'=73
|
'allow'=32
|
||||||
'exactly-one'=74
|
'poll'=33
|
||||||
'many-unique'=75
|
'queryable'=34
|
||||||
'many'=76
|
'rpc'=35
|
||||||
'ordered'=77
|
'query-reason'=36
|
||||||
'unit'=78
|
'type'=37
|
||||||
'watch-handle'=79
|
'object'=38
|
||||||
'message'=80
|
'storable'=39
|
||||||
'atom-ref'=81
|
'implements'=40
|
||||||
'interface-ref'=82
|
'ref'=41
|
||||||
'optional'=83
|
'fragment'=42
|
||||||
'list'=84
|
'import'=43
|
||||||
'record'=85
|
'external'=44
|
||||||
'bool'=86
|
'symbol'=45
|
||||||
'bytes'=87
|
'atom'=46
|
||||||
'double'=88
|
'interface'=47
|
||||||
'int32'=89
|
'interfaces'=48
|
||||||
'int64'=90
|
'package'=49
|
||||||
'string'=91
|
'value'=50
|
||||||
'uint32'=92
|
'field'=51
|
||||||
'uint64'=93
|
'relation'=52
|
||||||
'true'=94
|
'operation'=53
|
||||||
'false'=95
|
'function'=54
|
||||||
'null'=96
|
'constructor'=55
|
||||||
'->'=97
|
'constructs'=56
|
||||||
':'=98
|
'input'=57
|
||||||
';'=99
|
'conform'=58
|
||||||
','=100
|
'as'=59
|
||||||
'.'=101
|
'bind'=60
|
||||||
'{'=102
|
'static'=61
|
||||||
'}'=103
|
'to'=62
|
||||||
'['=104
|
'private'=63
|
||||||
']'=105
|
'shared'=64
|
||||||
'('=106
|
'state'=65
|
||||||
')'=107
|
'edge'=66
|
||||||
'<'=108
|
'projection'=67
|
||||||
'>'=109
|
'with'=68
|
||||||
'&'=110
|
'using'=69
|
||||||
'='=111
|
'accept'=70
|
||||||
|
'via'=71
|
||||||
|
'materialize'=72
|
||||||
|
'if'=73
|
||||||
|
'absent'=74
|
||||||
|
'on'=75
|
||||||
|
'policy'=76
|
||||||
|
'default'=77
|
||||||
|
'source'=78
|
||||||
|
'repository'=79
|
||||||
|
'commit'=80
|
||||||
|
'revision'=81
|
||||||
|
'semantic-major'=82
|
||||||
|
'on-delete'=83
|
||||||
|
'lifecycle'=84
|
||||||
|
'journal'=85
|
||||||
|
'subscription'=86
|
||||||
|
'retain-other'=87
|
||||||
|
'keyed'=88
|
||||||
|
'public-traversal'=89
|
||||||
|
'id'=90
|
||||||
|
'doc'=91
|
||||||
|
'mode'=92
|
||||||
|
'emits'=93
|
||||||
|
'receiver'=94
|
||||||
|
'requires'=95
|
||||||
|
'any'=96
|
||||||
|
'get'=97
|
||||||
|
'set'=98
|
||||||
|
'watch'=99
|
||||||
|
'start'=100
|
||||||
|
'stop'=101
|
||||||
|
'read'=102
|
||||||
|
'write'=103
|
||||||
|
'resolve'=104
|
||||||
|
'connect'=105
|
||||||
|
'disconnect'=106
|
||||||
|
'call'=107
|
||||||
|
'watch-start'=108
|
||||||
|
'watch-stop'=109
|
||||||
|
'subscribe'=110
|
||||||
|
'unsubscribe'=111
|
||||||
|
'register'=112
|
||||||
|
'crdt'=113
|
||||||
|
'optional-one'=114
|
||||||
|
'exactly-one'=115
|
||||||
|
'many-unique'=116
|
||||||
|
'many'=117
|
||||||
|
'ordered'=118
|
||||||
|
'unit'=119
|
||||||
|
'watch-handle'=120
|
||||||
|
'message'=121
|
||||||
|
'atom-ref'=122
|
||||||
|
'interface-ref'=123
|
||||||
|
'optional'=124
|
||||||
|
'list'=125
|
||||||
|
'record'=126
|
||||||
|
'bool'=127
|
||||||
|
'bytes'=128
|
||||||
|
'double'=129
|
||||||
|
'int32'=130
|
||||||
|
'int64'=131
|
||||||
|
'string'=132
|
||||||
|
'uint32'=133
|
||||||
|
'uint64'=134
|
||||||
|
'true'=135
|
||||||
|
'false'=136
|
||||||
|
'null'=137
|
||||||
|
'->'=138
|
||||||
|
':'=139
|
||||||
|
';'=140
|
||||||
|
','=141
|
||||||
|
'.'=142
|
||||||
|
'{'=143
|
||||||
|
'}'=144
|
||||||
|
'['=145
|
||||||
|
']'=146
|
||||||
|
'('=147
|
||||||
|
')'=148
|
||||||
|
'<'=149
|
||||||
|
'>'=150
|
||||||
|
'&'=151
|
||||||
|
'='=152
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import { WorkspaceItemContext } from "./QuixosCapabilityParser.js";
|
|||||||
import { ResourceImportDeclContext } from "./QuixosCapabilityParser.js";
|
import { ResourceImportDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { ExternalAtomDeclContext } from "./QuixosCapabilityParser.js";
|
import { ExternalAtomDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { ExternalInterfaceDeclContext } from "./QuixosCapabilityParser.js";
|
import { ExternalInterfaceDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { SymbolDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { ResourcePreambleContext } from "./QuixosCapabilityParser.js";
|
import { ResourcePreambleContext } from "./QuixosCapabilityParser.js";
|
||||||
import { AtomDeclContext } from "./QuixosCapabilityParser.js";
|
import { AtomDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { InterfaceResourceDeclContext } from "./QuixosCapabilityParser.js";
|
import { InterfaceResourceDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
@@ -20,16 +21,28 @@ import { TypeArgumentsContext } from "./QuixosCapabilityParser.js";
|
|||||||
import { TypeArgumentContext } from "./QuixosCapabilityParser.js";
|
import { TypeArgumentContext } from "./QuixosCapabilityParser.js";
|
||||||
import { TypeAliasDeclContext } from "./QuixosCapabilityParser.js";
|
import { TypeAliasDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { InterfaceMemberContext } from "./QuixosCapabilityParser.js";
|
import { InterfaceMemberContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { ModuleMemberContext } from "./QuixosCapabilityParser.js";
|
||||||
import { OperationMemberContext } from "./QuixosCapabilityParser.js";
|
import { OperationMemberContext } from "./QuixosCapabilityParser.js";
|
||||||
import { ValueMemberContext } from "./QuixosCapabilityParser.js";
|
import { ValueMemberContext } from "./QuixosCapabilityParser.js";
|
||||||
import { ValueMemberOperationContext } from "./QuixosCapabilityParser.js";
|
import { ValueMemberOperationContext } from "./QuixosCapabilityParser.js";
|
||||||
import { RelationshipMemberContext } from "./QuixosCapabilityParser.js";
|
import { RelationshipMemberContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { QueryReadContractContext } from "./QuixosCapabilityParser.js";
|
||||||
import { RelationshipOperationContext } from "./QuixosCapabilityParser.js";
|
import { RelationshipOperationContext } from "./QuixosCapabilityParser.js";
|
||||||
import { TargetConstraintContext } from "./QuixosCapabilityParser.js";
|
import { TargetConstraintContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageResourceDeclContext } from "./QuixosCapabilityParser.js";
|
import { PackageResourceDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageExportContext } from "./QuixosCapabilityParser.js";
|
import { PackageExportContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { ModuleExportContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { PresentationDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { PackageQueryContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { PackageQuerySpecializationContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { QueryClauseContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { FragmentVariableContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageOperationExportContext } from "./QuixosCapabilityParser.js";
|
import { PackageOperationExportContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageFunctionExportContext } from "./QuixosCapabilityParser.js";
|
import { PackageFunctionExportContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { AcceptanceClauseContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { PreparationClauseContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { ExecutionClauseContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { SelectedOperationsContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageConstructorExportContext } from "./QuixosCapabilityParser.js";
|
import { PackageConstructorExportContext } from "./QuixosCapabilityParser.js";
|
||||||
import { EventClauseContext } from "./QuixosCapabilityParser.js";
|
import { EventClauseContext } from "./QuixosCapabilityParser.js";
|
||||||
import { OperationModeContext } from "./QuixosCapabilityParser.js";
|
import { OperationModeContext } from "./QuixosCapabilityParser.js";
|
||||||
@@ -46,13 +59,19 @@ import { StoragePolicyContext } from "./QuixosCapabilityParser.js";
|
|||||||
import { EdgeDeclContext } from "./QuixosCapabilityParser.js";
|
import { EdgeDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { EdgeEndpointContext } from "./QuixosCapabilityParser.js";
|
import { EdgeEndpointContext } from "./QuixosCapabilityParser.js";
|
||||||
import { ConformanceDeclContext } from "./QuixosCapabilityParser.js";
|
import { ConformanceDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { ConformanceSourceArgumentsContext } from "./QuixosCapabilityParser.js";
|
||||||
import { ConformanceItemContext } from "./QuixosCapabilityParser.js";
|
import { ConformanceItemContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { ModuleBindingDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { InitializationDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { InitializationFieldContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { CreationOwnerMappingContext } from "./QuixosCapabilityParser.js";
|
||||||
import { StateFieldBindingDeclContext } from "./QuixosCapabilityParser.js";
|
import { StateFieldBindingDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { RelationshipMaterializationDeclContext } from "./QuixosCapabilityParser.js";
|
import { RelationshipMaterializationDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { OperationBindingDeclContext } from "./QuixosCapabilityParser.js";
|
import { OperationBindingDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { MemberOperationRefContext } from "./QuixosCapabilityParser.js";
|
import { MemberOperationRefContext } from "./QuixosCapabilityParser.js";
|
||||||
import { OperationNameContext } from "./QuixosCapabilityParser.js";
|
import { OperationNameContext } from "./QuixosCapabilityParser.js";
|
||||||
import { OperationProviderContext } from "./QuixosCapabilityParser.js";
|
import { OperationProviderContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { AcceptanceBindingContext } from "./QuixosCapabilityParser.js";
|
||||||
import { StatePrimitiveContext } from "./QuixosCapabilityParser.js";
|
import { StatePrimitiveContext } from "./QuixosCapabilityParser.js";
|
||||||
import { EdgePrimitiveContext } from "./QuixosCapabilityParser.js";
|
import { EdgePrimitiveContext } from "./QuixosCapabilityParser.js";
|
||||||
import { DependencyBindingBlockContext } from "./QuixosCapabilityParser.js";
|
import { DependencyBindingBlockContext } from "./QuixosCapabilityParser.js";
|
||||||
@@ -60,6 +79,7 @@ import { DependencyBindingContext } from "./QuixosCapabilityParser.js";
|
|||||||
import { ConstructorBindingDeclContext } from "./QuixosCapabilityParser.js";
|
import { ConstructorBindingDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { ValueTypeContext } from "./QuixosCapabilityParser.js";
|
import { ValueTypeContext } from "./QuixosCapabilityParser.js";
|
||||||
import { RecordFieldContext } from "./QuixosCapabilityParser.js";
|
import { RecordFieldContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { VariantCaseContext } from "./QuixosCapabilityParser.js";
|
||||||
import { ScalarTypeContext } from "./QuixosCapabilityParser.js";
|
import { ScalarTypeContext } from "./QuixosCapabilityParser.js";
|
||||||
import { CardinalityContext } from "./QuixosCapabilityParser.js";
|
import { CardinalityContext } from "./QuixosCapabilityParser.js";
|
||||||
import { JsonLiteralContext } from "./QuixosCapabilityParser.js";
|
import { JsonLiteralContext } from "./QuixosCapabilityParser.js";
|
||||||
@@ -126,6 +146,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitExternalInterfaceDecl?: (ctx: ExternalInterfaceDeclContext) => Result;
|
visitExternalInterfaceDecl?: (ctx: ExternalInterfaceDeclContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.symbolDecl`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitSymbolDecl?: (ctx: SymbolDeclContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.resourcePreamble`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.resourcePreamble`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
@@ -186,6 +212,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitInterfaceMember?: (ctx: InterfaceMemberContext) => Result;
|
visitInterfaceMember?: (ctx: InterfaceMemberContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.moduleMember`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitModuleMember?: (ctx: ModuleMemberContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.operationMember`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.operationMember`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
@@ -210,6 +242,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitRelationshipMember?: (ctx: RelationshipMemberContext) => Result;
|
visitRelationshipMember?: (ctx: RelationshipMemberContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.queryReadContract`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitQueryReadContract?: (ctx: QueryReadContractContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.relationshipOperation`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.relationshipOperation`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
@@ -234,6 +272,42 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitPackageExport?: (ctx: PackageExportContext) => Result;
|
visitPackageExport?: (ctx: PackageExportContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.moduleExport`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitModuleExport?: (ctx: ModuleExportContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.presentationDecl`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitPresentationDecl?: (ctx: PresentationDeclContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.packageQuery`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitPackageQuery?: (ctx: PackageQueryContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.packageQuerySpecialization`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitPackageQuerySpecialization?: (ctx: PackageQuerySpecializationContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.queryClause`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitQueryClause?: (ctx: QueryClauseContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.fragmentVariable`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitFragmentVariable?: (ctx: FragmentVariableContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.packageOperationExport`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.packageOperationExport`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
@@ -246,6 +320,30 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitPackageFunctionExport?: (ctx: PackageFunctionExportContext) => Result;
|
visitPackageFunctionExport?: (ctx: PackageFunctionExportContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.acceptanceClause`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitAcceptanceClause?: (ctx: AcceptanceClauseContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.preparationClause`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitPreparationClause?: (ctx: PreparationClauseContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.executionClause`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitExecutionClause?: (ctx: ExecutionClauseContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.selectedOperations`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitSelectedOperations?: (ctx: SelectedOperationsContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.packageConstructorExport`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.packageConstructorExport`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
@@ -342,12 +440,42 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitConformanceDecl?: (ctx: ConformanceDeclContext) => Result;
|
visitConformanceDecl?: (ctx: ConformanceDeclContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.conformanceSourceArguments`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitConformanceSourceArguments?: (ctx: ConformanceSourceArgumentsContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.conformanceItem`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.conformanceItem`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitConformanceItem?: (ctx: ConformanceItemContext) => Result;
|
visitConformanceItem?: (ctx: ConformanceItemContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.moduleBindingDecl`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitModuleBindingDecl?: (ctx: ModuleBindingDeclContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.initializationDecl`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitInitializationDecl?: (ctx: InitializationDeclContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.initializationField`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitInitializationField?: (ctx: InitializationFieldContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.creationOwnerMapping`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitCreationOwnerMapping?: (ctx: CreationOwnerMappingContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.stateFieldBindingDecl`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.stateFieldBindingDecl`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
@@ -384,6 +512,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitOperationProvider?: (ctx: OperationProviderContext) => Result;
|
visitOperationProvider?: (ctx: OperationProviderContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.acceptanceBinding`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitAcceptanceBinding?: (ctx: AcceptanceBindingContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.statePrimitive`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.statePrimitive`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
@@ -426,6 +560,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitRecordField?: (ctx: RecordFieldContext) => Result;
|
visitRecordField?: (ctx: RecordFieldContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.variantCase`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitVariantCase?: (ctx: VariantCaseContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.scalarType`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.scalarType`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export class GenericSourceTypes {
|
|||||||
readonly applications = new Map<string, InterfaceRevision>();
|
readonly applications = new Map<string, InterfaceRevision>();
|
||||||
readonly aliases = new Map<string, ValueAliasDefinition>();
|
readonly aliases = new Map<string, ValueAliasDefinition>();
|
||||||
parameters = new Map<string, TypeParameter>();
|
parameters = new Map<string, TypeParameter>();
|
||||||
|
bindings = new Map<string, ClosedTypeArgument>();
|
||||||
self?: AtomId;
|
self?: AtomId;
|
||||||
private active: string[] = [];
|
private active: string[] = [];
|
||||||
private applicationCount = 0;
|
private applicationCount = 0;
|
||||||
@@ -42,7 +43,7 @@ export class GenericSourceTypes {
|
|||||||
|
|
||||||
environment(): GenericTypeEnvironment {
|
environment(): GenericTypeEnvironment {
|
||||||
return {
|
return {
|
||||||
arguments: new Map(),
|
arguments: this.bindings,
|
||||||
aliases: this.aliases,
|
aliases: this.aliases,
|
||||||
self: this.self,
|
self: this.self,
|
||||||
applyInterface: (id, args) => this.apply(id, args).revisionId,
|
applyInterface: (id, args) => this.apply(id, args).revisionId,
|
||||||
@@ -108,6 +109,8 @@ export class GenericSourceTypes {
|
|||||||
return parameter?.kind === "value" && Boolean(parameter.storable);
|
return parameter?.kind === "value" && Boolean(parameter.storable);
|
||||||
}
|
}
|
||||||
if (type.kind === "list" || type.kind === "optional") return storable(type.value, depth + 1);
|
if (type.kind === "list" || type.kind === "optional") return storable(type.value, depth + 1);
|
||||||
|
if (type.kind === "record") return Object.values(type.fields).every((field) => storable(field, depth + 1));
|
||||||
|
if (type.kind === "variant") return Object.values(type.cases).every((entry) => storable(entry.value, depth + 1));
|
||||||
if (type.kind === "alias") {
|
if (type.kind === "alias") {
|
||||||
const alias = aliases.get(type.definitionId);
|
const alias = aliases.get(type.definitionId);
|
||||||
if (!alias || alias.parameters.length !== type.arguments.length) return false;
|
if (!alias || alias.parameters.length !== type.arguments.length) return false;
|
||||||
@@ -299,6 +302,24 @@ export class GenericSourceTypes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expression(context: ValueTypeContext): ValueTypeExpression {
|
expression(context: ValueTypeContext): ValueTypeExpression {
|
||||||
|
if (context.VARIANT()) {
|
||||||
|
const entries = context.variantCase().map(
|
||||||
|
(entry) =>
|
||||||
|
[
|
||||||
|
entry.identifier().getText(),
|
||||||
|
{
|
||||||
|
id: literal(entry.stringLiteral()),
|
||||||
|
value: this.expression(entry.valueType()),
|
||||||
|
},
|
||||||
|
] as const,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
new Set(entries.map(([label]) => label)).size !== entries.length ||
|
||||||
|
new Set(entries.map(([, entry]) => entry.id)).size !== entries.length
|
||||||
|
)
|
||||||
|
throw new GenericTypeError("duplicate-variant-case", "variant", "Variant labels and case IDs must be unique");
|
||||||
|
return { kind: "variant", id: literal(context.stringLiteral()!), cases: Object.fromEntries(entries) };
|
||||||
|
}
|
||||||
if (context.scalarType())
|
if (context.scalarType())
|
||||||
return {
|
return {
|
||||||
kind: "scalar",
|
kind: "scalar",
|
||||||
@@ -318,6 +339,12 @@ export class GenericSourceTypes {
|
|||||||
return { kind: "record", fields: Object.fromEntries(fields) };
|
return { kind: "record", fields: Object.fromEntries(fields) };
|
||||||
}
|
}
|
||||||
const name = context.identifier()!.getText();
|
const name = context.identifier()!.getText();
|
||||||
|
if (name === "change" && context.typeArguments()) {
|
||||||
|
const args = this.arguments(context.typeArguments());
|
||||||
|
if (args.length !== 1 || args[0]!.kind !== "value")
|
||||||
|
throw new GenericTypeError("invalid-change-type", name, "change<T> requires one materialized value type");
|
||||||
|
return { kind: "change", value: args[0]!.type };
|
||||||
|
}
|
||||||
if (context.ATOM_REF()) return { kind: "object-ref", expectation: this.atom(name) };
|
if (context.ATOM_REF()) return { kind: "object-ref", expectation: this.atom(name) };
|
||||||
if (context.INTERFACE_REF())
|
if (context.INTERFACE_REF())
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const createGitCapabilityResolver = async (options: {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
for (const entry of document.resources ?? []) {
|
for (const entry of document.resources ?? []) {
|
||||||
if (entry.kind !== "interface" && entry.kind !== "package") {
|
if (entry.kind !== "interface" && entry.kind !== "package" && entry.kind !== "build") {
|
||||||
throw new Error(`Snapshot map has unsupported resource kind ${entry.kind}`);
|
throw new Error(`Snapshot map has unsupported resource kind ${entry.kind}`);
|
||||||
}
|
}
|
||||||
const key = sourceKey(entry.kind, entry.repository, entry.commit);
|
const key = sourceKey(entry.kind, entry.repository, entry.commit);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const node = (value: unknown): value is Node =>
|
|||||||
* Ambiguous/custom wiring is left alone with an actionable error. */
|
* Ambiguous/custom wiring is left alone with an actionable error. */
|
||||||
export function addImplementation(
|
export function addImplementation(
|
||||||
text: string,
|
text: string,
|
||||||
factory: "createRuntime" | "serveMigration",
|
factory: "createRuntime" | "serveMigration" | "createRegistry",
|
||||||
key: string,
|
key: string,
|
||||||
importPath: string,
|
importPath: string,
|
||||||
migrationOnly = false,
|
migrationOnly = false,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { realpath } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/** Registration may name the workbench through its guest alias. Only that
|
||||||
|
* outer alias is permitted: resources and each checkout remain physical,
|
||||||
|
* directly contained directories. Persist the resulting root-relative path.
|
||||||
|
*/
|
||||||
|
export async function managedResourcePath(workbench: string, directory: string): Promise<string> {
|
||||||
|
const root = await realpath(workbench);
|
||||||
|
const selected = path.resolve(root, directory);
|
||||||
|
const name = path.basename(selected);
|
||||||
|
const resources = path.dirname(selected);
|
||||||
|
if (path.basename(resources) !== "resources" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name))
|
||||||
|
throw new Error(`Repository is outside the managed workbench: ${directory}`);
|
||||||
|
const relative = `resources/${name}`;
|
||||||
|
if ((await realpath(path.dirname(resources))) !== root || (await realpath(selected)) !== path.join(root, relative))
|
||||||
|
throw new Error(`Managed repository path crosses a symlink or escapes the workbench: ${directory}`);
|
||||||
|
return relative;
|
||||||
|
}
|
||||||
+1309
-35
File diff suppressed because it is too large
Load Diff
@@ -15,11 +15,17 @@ import {
|
|||||||
} from "./assembly.js";
|
} from "./assembly.js";
|
||||||
import { createGitCapabilityResolver } from "./git-resolver.js";
|
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||||
import { snapshotCommit, buildImmutableCandidate } from "./checked-build.js";
|
import { snapshotCommit, buildImmutableCandidate } from "./checked-build.js";
|
||||||
import { planEvolution, type WorkspaceRevision, type EvolutionReview } from "../capability-model/index.js";
|
import {
|
||||||
|
planEvolution,
|
||||||
|
reviewSemanticMajors,
|
||||||
|
type WorkspaceRevision,
|
||||||
|
type EvolutionReview,
|
||||||
|
} from "../capability-model/index.js";
|
||||||
import { withFileLock } from "./file-lock.js";
|
import { withFileLock } from "./file-lock.js";
|
||||||
|
import { managedResourcePath } from "./managed-source-path.js";
|
||||||
const execFile = promisify(callback);
|
const execFile = promisify(callback);
|
||||||
type Source = { repository: string; commit: string };
|
type Source = { repository: string; commit: string };
|
||||||
export type UpgradeNode = { kind: "workspace" | "package" | "interface"; directory: string; source: Source };
|
export type UpgradeNode = { kind: "workspace" | "package" | "interface" | "build"; directory: string; source: Source };
|
||||||
export type UpgradeSpec = {
|
export type UpgradeSpec = {
|
||||||
nodes: UpgradeNode[];
|
nodes: UpgradeNode[];
|
||||||
quixos?: Source;
|
quixos?: Source;
|
||||||
@@ -93,7 +99,8 @@ const writeJournal = async (filename: string, journal: unknown) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const discoverUpgradeSpec = async (workbench: string): Promise<UpgradeSpec> => {
|
export const discoverUpgradeSpec = async (workbenchPath: string): Promise<UpgradeSpec> => {
|
||||||
|
const workbench = await fs.realpath(workbenchPath);
|
||||||
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"));
|
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"));
|
||||||
const root = await location(workbench, "root");
|
const root = await location(workbench, "root");
|
||||||
const nodes: UpgradeNode[] = [
|
const nodes: UpgradeNode[] = [
|
||||||
@@ -105,11 +112,15 @@ export const discoverUpgradeSpec = async (workbench: string): Promise<UpgradeSpe
|
|||||||
commit: await command(root, "jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"]),
|
commit: await command(root, "jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"]),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...graph.resources.map((entry: { kind: "interface" | "package"; directory: string; source: Source }) => ({
|
...(await Promise.all(
|
||||||
|
graph.resources.map(
|
||||||
|
async (entry: { kind: "interface" | "package" | "build"; directory: string; source: Source }) => ({
|
||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
source: entry.source,
|
source: entry.source,
|
||||||
directory: path.relative(workbench, path.resolve(workbench, entry.directory)),
|
directory: await managedResourcePath(workbench, entry.directory),
|
||||||
})),
|
}),
|
||||||
|
),
|
||||||
|
)),
|
||||||
];
|
];
|
||||||
let baseline: string | undefined;
|
let baseline: string | undefined;
|
||||||
try {
|
try {
|
||||||
@@ -139,7 +150,7 @@ export const planPinUpgrades = async (workbenchPath: string, spec: UpgradeSpec):
|
|||||||
const keys = new Map<string, string>();
|
const keys = new Map<string, string>();
|
||||||
for (const node of spec.nodes) {
|
for (const node of spec.nodes) {
|
||||||
validSource(node.source);
|
validSource(node.source);
|
||||||
if (!["workspace", "package", "interface"].includes(node.kind) || keys.has(sourceKey(node)))
|
if (!["workspace", "package", "interface", "build"].includes(node.kind) || keys.has(sourceKey(node)))
|
||||||
throw new Error("Duplicate/invalid upgrade resource identity");
|
throw new Error("Duplicate/invalid upgrade resource identity");
|
||||||
keys.set(sourceKey(node), node.directory);
|
keys.set(sourceKey(node), node.directory);
|
||||||
}
|
}
|
||||||
@@ -187,6 +198,34 @@ export type UpgradeEffects = {
|
|||||||
snapshot(root: string): Promise<string>;
|
snapshot(root: string): Promise<string>;
|
||||||
publish(root: string, commit: string): Promise<void>;
|
publish(root: string, commit: string): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Review a successfully built immutable candidate using the same boundary as
|
||||||
|
* ordinary authoring checks. Execution storage compatibility is checked against
|
||||||
|
* the current graph at activation; the legacy evolution model is not its schema.
|
||||||
|
*/
|
||||||
|
export async function reviewUpgradeCandidate({
|
||||||
|
artifact,
|
||||||
|
baseline,
|
||||||
|
candidate,
|
||||||
|
reviews = [],
|
||||||
|
}: {
|
||||||
|
artifact: string;
|
||||||
|
baseline: WorkspaceRevision | null;
|
||||||
|
candidate: WorkspaceRevision;
|
||||||
|
reviews?: EvolutionReview[];
|
||||||
|
}) {
|
||||||
|
let execution = false;
|
||||||
|
try {
|
||||||
|
await fs.access(path.join(artifact, "execution-plan.json"));
|
||||||
|
execution = true;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
return execution
|
||||||
|
? { filename: "semantic-review.json", review: reviewSemanticMajors(baseline, candidate, reviews) }
|
||||||
|
: { filename: "evolution.json", review: planEvolution(baseline, candidate, { reviews }) };
|
||||||
|
}
|
||||||
|
|
||||||
const effects: UpgradeEffects = {
|
const effects: UpgradeEffects = {
|
||||||
async check(node, root, output, spec) {
|
async check(node, root, output, spec) {
|
||||||
if (node.kind === "workspace" && !spec.baseline && !spec.bootstrap)
|
if (node.kind === "workspace" && !spec.baseline && !spec.bootstrap)
|
||||||
@@ -206,10 +245,15 @@ const effects: UpgradeEffects = {
|
|||||||
? (JSON.parse(await fs.readFile(spec.baseline, "utf8")) as WorkspaceRevision)
|
? (JSON.parse(await fs.readFile(spec.baseline, "utf8")) as WorkspaceRevision)
|
||||||
: null;
|
: null;
|
||||||
const reviews = spec.reviews ? (JSON.parse(await fs.readFile(spec.reviews, "utf8")) as EvolutionReview[]) : [];
|
const reviews = spec.reviews ? (JSON.parse(await fs.readFile(spec.reviews, "utf8")) as EvolutionReview[]) : [];
|
||||||
const evolution = planEvolution(baseline, JSON.parse(candidate), { reviews });
|
const { filename, review } = await reviewUpgradeCandidate({
|
||||||
await fs.writeFile(path.join(output, "evolution.json"), JSON.stringify(evolution, null, 2));
|
artifact,
|
||||||
if (evolution.blockers.length)
|
baseline,
|
||||||
throw new Error(`Refactor required in ${node.directory}: ${evolution.blockers.join("; ")}`);
|
candidate: JSON.parse(candidate),
|
||||||
|
reviews,
|
||||||
|
});
|
||||||
|
await fs.writeFile(path.join(output, filename), JSON.stringify(review, null, 2));
|
||||||
|
if (review.blockers.length)
|
||||||
|
throw new Error(`Refactor required in ${node.directory}: ${review.blockers.join("; ")}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async snapshot(root) {
|
async snapshot(root) {
|
||||||
@@ -368,10 +412,7 @@ export const applyPinUpgrades = async (
|
|||||||
previous.resources.map(async (entry: { kind: string; source: Source; directory: string }) => ({
|
previous.resources.map(async (entry: { kind: string; source: Source; directory: string }) => ({
|
||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
...entry.source,
|
...entry.source,
|
||||||
directory: await location(
|
directory: await location(plan.workbench, await managedResourcePath(plan.workbench, entry.directory)),
|
||||||
plan.workbench,
|
|
||||||
path.relative(plan.workbench, path.resolve(plan.workbench, entry.directory)),
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
for (const node of plan.nodes.filter((entry) => entry.kind !== "workspace")) {
|
for (const node of plan.nodes.filter((entry) => entry.kind !== "workspace")) {
|
||||||
@@ -423,14 +464,14 @@ export const applyPinUpgrades = async (
|
|||||||
const directory = await location(plan.workbench, node.directory);
|
const directory = await location(plan.workbench, node.directory);
|
||||||
const standalone = await compileCapabilityResourceRepository({
|
const standalone = await compileCapabilityResourceRepository({
|
||||||
rootDirectory: directory,
|
rootDirectory: directory,
|
||||||
kind: node.kind as "package" | "interface",
|
kind: node.kind as "package" | "interface" | "build",
|
||||||
source,
|
source,
|
||||||
resolveResource,
|
resolveResource,
|
||||||
});
|
});
|
||||||
for (const dependency of standalone.resources) resources.set(dependency.key, dependency);
|
for (const dependency of standalone.resources) resources.set(dependency.key, dependency);
|
||||||
resources.set(key, {
|
resources.set(key, {
|
||||||
key,
|
key,
|
||||||
kind: node.kind as "package" | "interface",
|
kind: node.kind as "package" | "interface" | "build",
|
||||||
source,
|
source,
|
||||||
directory,
|
directory,
|
||||||
lock: standalone.lock,
|
lock: standalone.lock,
|
||||||
@@ -443,18 +484,22 @@ export const applyPinUpgrades = async (
|
|||||||
quixos: compiled.lock.quixos,
|
quixos: compiled.lock.quixos,
|
||||||
directResources: [...compiled.directResources.entries()].map(([bindingKey, node]) => {
|
directResources: [...compiled.directResources.entries()].map(([bindingKey, node]) => {
|
||||||
const [kind, binding] = bindingKey.split("\0");
|
const [kind, binding] = bindingKey.split("\0");
|
||||||
return { kind, binding, resourceKey: node.key, directory: node.directory };
|
return { kind, binding, resourceKey: node.key, directory: path.relative(plan.workbench, node.directory) };
|
||||||
}),
|
}),
|
||||||
resources: [...resources.values()].map((node) => ({
|
resources: [...resources.values()].map((node) => ({
|
||||||
key: node.key,
|
key: node.key,
|
||||||
kind: node.kind,
|
kind: node.kind,
|
||||||
source: node.source,
|
source: node.source,
|
||||||
directory: node.directory,
|
directory: path.relative(plan.workbench, node.directory),
|
||||||
|
...(node.resource.kind === "build"
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
resourceId:
|
resourceId:
|
||||||
node.resource.kind === "interface"
|
node.resource.kind === "interface"
|
||||||
? node.resource.revision.interfaceId
|
? node.resource.revision.interfaceId
|
||||||
: node.resource.revision.packageId,
|
: node.resource.revision.packageId,
|
||||||
revisionId: node.resource.revision.revisionId,
|
revisionId: node.resource.revision.revisionId,
|
||||||
|
}),
|
||||||
dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({
|
dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({
|
||||||
binding,
|
binding,
|
||||||
resourceKey: dependency.key,
|
resourceKey: dependency.key,
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** Observations belong in logs, never in content-addressed compiler outputs. */
|
||||||
|
export const profile = (phase: string, started: number, details: Record<string, unknown> = {}) => {
|
||||||
|
if (process.env.QUIXOS_TRACE_PROFILE === "1")
|
||||||
|
console.error(`[qx-profile] ${JSON.stringify({ phase, ms: Math.round(performance.now() - started), ...details })}`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import type { PackageQueryGenerator } from "./assembly.js";
|
||||||
|
|
||||||
|
/** The build invoker selects this executable explicitly. Never resolve a
|
||||||
|
* generator by a capability name or execute a module discovered in user QX. */
|
||||||
|
export async function loadQueryGenerator(filename?: string): Promise<PackageQueryGenerator | undefined> {
|
||||||
|
if (!filename) return undefined;
|
||||||
|
const module = await import(pathToFileURL(path.resolve(filename)).href);
|
||||||
|
if (typeof module.generatePackageQueries !== "function")
|
||||||
|
throw new Error("Query generator must export generatePackageQueries");
|
||||||
|
const generator: PackageQueryGenerator = module.generatePackageQueries;
|
||||||
|
if (module.completeWorkspace !== undefined) {
|
||||||
|
if (typeof module.completeWorkspace !== "function") throw new Error("completeWorkspace must be a function");
|
||||||
|
generator.completeWorkspace = module.completeWorkspace;
|
||||||
|
}
|
||||||
|
return generator;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { parse } from "graphql";
|
||||||
|
import type { ScaffoldRecipe } from "./scaffold-recipes.js";
|
||||||
|
import type { StructuralRequest } from "./structural-plan.js";
|
||||||
|
import { parseQx, walkSyntax } from "./source.js";
|
||||||
|
|
||||||
|
/** Insert the authored query and its document together; placement never changes implicitly. */
|
||||||
|
export async function scaffoldQuery(root: string, prefix: string, spec: ScaffoldRecipe): Promise<StructuralRequest> {
|
||||||
|
if (
|
||||||
|
!spec.name ||
|
||||||
|
!/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) ||
|
||||||
|
!spec.id ||
|
||||||
|
!spec.declaration ||
|
||||||
|
typeof spec.queryDocument !== "string"
|
||||||
|
)
|
||||||
|
throw new Error("Query scaffold requires name, id, declaration and queryDocument");
|
||||||
|
const source = `package Draft id "draft" revision "draft@1" { ${spec.declaration} }`;
|
||||||
|
const syntax = parseQx(source);
|
||||||
|
const pkg = syntax.root.children.find((node) => node.kind === "packageResourceDecl");
|
||||||
|
const queries = [...walkSyntax(syntax.root)].filter((node) => node.kind === "packageQuery");
|
||||||
|
const query = queries[0];
|
||||||
|
if (
|
||||||
|
syntax.diagnostics.length ||
|
||||||
|
syntax.root.children.length !== 1 ||
|
||||||
|
!pkg ||
|
||||||
|
queries.length !== 1 ||
|
||||||
|
!query ||
|
||||||
|
pkg.children.filter((node) => node.kind === "packageExport").length !== 1 ||
|
||||||
|
pkg.children.some((node) => !["identifier", "stringLiteral", "packageExport"].includes(node.kind))
|
||||||
|
)
|
||||||
|
throw new Error("Expected one QX query declaration");
|
||||||
|
const identifier = query.children.find((node) => node.kind === "identifier")!;
|
||||||
|
const literals = query.children
|
||||||
|
.filter((node) => node.kind === "stringLiteral")
|
||||||
|
.map((node) => JSON.parse(source.slice(node.start, node.end)) as string);
|
||||||
|
const [id, document, operation] = literals;
|
||||||
|
if (source.slice(identifier.start, identifier.end) !== spec.name || id !== spec.id)
|
||||||
|
throw new Error("Query declaration name/ID must match its registration");
|
||||||
|
if (!document || !/^queries\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.graphql$/.test(document))
|
||||||
|
throw new Error("Query document must be a contained queries/*.graphql path");
|
||||||
|
const graphql = parse(spec.queryDocument);
|
||||||
|
const operations = graphql.definitions.filter((definition) => definition.kind === "OperationDefinition");
|
||||||
|
if (operations.length !== 1 || operations[0]!.operation !== "query" || operations[0]!.name?.value !== operation)
|
||||||
|
throw new Error("GraphQL document must contain the declared named query operation");
|
||||||
|
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
|
||||||
|
const existing = parseQx(authored);
|
||||||
|
const parent = existing.root.children.find((node) => node.kind === "packageResourceDecl");
|
||||||
|
if (existing.diagnostics.length || !parent) throw new Error("Fix package.qx before adding a query");
|
||||||
|
for (const node of parent.children.filter((node) => node.kind === "packageExport").flatMap((node) => node.children)) {
|
||||||
|
const name = node.children.find((child) => child.kind === "identifier");
|
||||||
|
const literal = node.children.find((child) => child.kind === "stringLiteral");
|
||||||
|
if (
|
||||||
|
(name && authored.slice(name.start, name.end) === spec.name) ||
|
||||||
|
(literal && JSON.parse(authored.slice(literal.start, literal.end)) === spec.id)
|
||||||
|
)
|
||||||
|
throw new Error("New query requires a unique name and ID");
|
||||||
|
}
|
||||||
|
const parentID = parent.children.find((node) => node.kind === "stringLiteral")!;
|
||||||
|
return {
|
||||||
|
kind: "package",
|
||||||
|
source: spec.source,
|
||||||
|
resourceRoot: spec.directory,
|
||||||
|
validation: "syntax",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file: prefix + "package.qx",
|
||||||
|
edits: [
|
||||||
|
{
|
||||||
|
operation: "append",
|
||||||
|
parent: { kind: "packageResourceDecl", id: JSON.parse(authored.slice(parentID.start, parentID.end)) },
|
||||||
|
source: spec.declaration,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ file: prefix + document, create: spec.queryDocument },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,10 +5,13 @@ import process from "node:process";
|
|||||||
import { bindingSchema } from "../bindings/index.js";
|
import { bindingSchema } from "../bindings/index.js";
|
||||||
import { compileCapabilityResourceRepository, type ResolvedCapabilityResource } from "./assembly.js";
|
import { compileCapabilityResourceRepository, type ResolvedCapabilityResource } from "./assembly.js";
|
||||||
import { createGitCapabilityResolver } from "./git-resolver.js";
|
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||||
|
import { loadQueryGenerator } from "./query-generator.js";
|
||||||
|
import { loadCheckedResources } from "./checked-resources.js";
|
||||||
|
|
||||||
const usage = `usage: quixos-resource-compile --root DIRECTORY --kind interface|package
|
const usage = `usage: quixos-resource-compile --root DIRECTORY --kind interface|package|build
|
||||||
--repository URL --commit GIT_REV --checkout-root DIRECTORY
|
--repository URL --commit GIT_REV --checkout-root DIRECTORY
|
||||||
[--snapshot-map PATH] [--snapshot-only true] [--graph-out PATH] [--schema-out PATH]
|
[--snapshot-map PATH] [--snapshot-only true] [--graph-out PATH] [--schema-out PATH]
|
||||||
|
[--query-generator BUILD_MODULE_PATH] [--checked-resources NIX_MAP]
|
||||||
|
|
||||||
Resolves a standalone resource's recursive dependency-lock graph, validates
|
Resolves a standalone resource's recursive dependency-lock graph, validates
|
||||||
its interface.qx or package.qx manifest, and emits the checked resource as JSON.`;
|
its interface.qx or package.qx manifest, and emits the checked resource as JSON.`;
|
||||||
@@ -30,6 +33,8 @@ const parseArgs = (args: string[]) => {
|
|||||||
"--snapshot-only",
|
"--snapshot-only",
|
||||||
"--graph-out",
|
"--graph-out",
|
||||||
"--schema-out",
|
"--schema-out",
|
||||||
|
"--query-generator",
|
||||||
|
"--checked-resources",
|
||||||
].includes(key) ||
|
].includes(key) ||
|
||||||
values.has(key)
|
values.has(key)
|
||||||
)
|
)
|
||||||
@@ -41,7 +46,13 @@ const parseArgs = (args: string[]) => {
|
|||||||
const repository = values.get("--repository");
|
const repository = values.get("--repository");
|
||||||
const commit = values.get("--commit")?.toLowerCase();
|
const commit = values.get("--commit")?.toLowerCase();
|
||||||
const checkoutRoot = values.get("--checkout-root");
|
const checkoutRoot = values.get("--checkout-root");
|
||||||
if (!rootDirectory || !repository || !commit || !checkoutRoot || (kind !== "interface" && kind !== "package")) {
|
if (
|
||||||
|
!rootDirectory ||
|
||||||
|
!repository ||
|
||||||
|
!commit ||
|
||||||
|
!checkoutRoot ||
|
||||||
|
(kind !== "interface" && kind !== "package" && kind !== "build")
|
||||||
|
) {
|
||||||
throw new Error(usage);
|
throw new Error(usage);
|
||||||
}
|
}
|
||||||
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(commit)) {
|
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(commit)) {
|
||||||
@@ -59,6 +70,8 @@ const parseArgs = (args: string[]) => {
|
|||||||
snapshotOnly: values.get("--snapshot-only") === "true",
|
snapshotOnly: values.get("--snapshot-only") === "true",
|
||||||
graphOut: values.get("--graph-out"),
|
graphOut: values.get("--graph-out"),
|
||||||
schemaOut: values.get("--schema-out"),
|
schemaOut: values.get("--schema-out"),
|
||||||
|
queryGenerator: values.get("--query-generator"),
|
||||||
|
checkedResources: values.get("--checked-resources"),
|
||||||
} as const;
|
} as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,9 +80,13 @@ const graphEntry = (node: ResolvedCapabilityResource) => ({
|
|||||||
kind: node.kind,
|
kind: node.kind,
|
||||||
source: node.source,
|
source: node.source,
|
||||||
directory: node.directory,
|
directory: node.directory,
|
||||||
|
...(node.resource.kind === "build"
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
resourceId:
|
resourceId:
|
||||||
node.resource.kind === "interface" ? node.resource.revision.interfaceId : node.resource.revision.packageId,
|
node.resource.kind === "interface" ? node.resource.revision.interfaceId : node.resource.revision.packageId,
|
||||||
revisionId: node.resource.revision.revisionId,
|
revisionId: node.resource.revision.revisionId,
|
||||||
|
}),
|
||||||
dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({
|
dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({
|
||||||
binding,
|
binding,
|
||||||
resourceKey: dependency.key,
|
resourceKey: dependency.key,
|
||||||
@@ -96,6 +113,8 @@ const main = async () => {
|
|||||||
commit: options.commit,
|
commit: options.commit,
|
||||||
},
|
},
|
||||||
resolveResource,
|
resolveResource,
|
||||||
|
generateQueries: await loadQueryGenerator(options.queryGenerator),
|
||||||
|
checkedResource: await loadCheckedResources(options.checkedResources),
|
||||||
});
|
});
|
||||||
if (options.graphOut) {
|
if (options.graphOut) {
|
||||||
await writeFile(
|
await writeFile(
|
||||||
|
|||||||
@@ -1,43 +1,26 @@
|
|||||||
import fs from "node:fs/promises";
|
import type { GitSource } from "../resource-lock/index.js";
|
||||||
import path from "node:path";
|
|
||||||
import { contentDigest } from "../capability-model/evolution.js";
|
|
||||||
import {
|
|
||||||
validateMigrationCatalog,
|
|
||||||
type MigrationCatalog,
|
|
||||||
type MigrationDeclaration,
|
|
||||||
} from "../capability-model/migrations.js";
|
|
||||||
import { formatQuixosLock, type GitSource } from "../resource-lock/index.js";
|
|
||||||
import { parseQx, walkSyntax } from "./source.js";
|
|
||||||
import type { StructuralRequest } from "./structural-plan.js";
|
import type { StructuralRequest } from "./structural-plan.js";
|
||||||
import { addImplementation } from "./implementation-edit.js";
|
import { executionScaffold, isExecutionScaffold } from "./execution-scaffold.js";
|
||||||
import { reactPlatformTypes } from "../bindings/react-platform.js";
|
import { scaffoldController } from "./controller-scaffold.js";
|
||||||
|
import { scaffoldComponent } from "./component-scaffold.js";
|
||||||
|
import { scaffoldQuery } from "./query-scaffold.js";
|
||||||
type Source = { repository: string; commit: string };
|
type Source = { repository: string; commit: string };
|
||||||
type PackageEditModel = {
|
|
||||||
generatedBy: "qx-scaffold-v1";
|
|
||||||
name: string;
|
|
||||||
id: string;
|
|
||||||
revision: string;
|
|
||||||
exports: { name: string; id: string; file: string; migration?: boolean }[];
|
|
||||||
};
|
|
||||||
export type ScaffoldRecipe = {
|
export type ScaffoldRecipe = {
|
||||||
template?: "typescript" | "typescript-react";
|
template?: "typescript-execution" | "typescript-component";
|
||||||
source: Source;
|
source: Source;
|
||||||
directory?: string;
|
directory?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
revision?: string;
|
revision?: string;
|
||||||
declaration?: string;
|
declaration?: string;
|
||||||
/** Initial authored files for a composed recipe; only used for a new package. */
|
queryDocument?: string;
|
||||||
initialFiles?: Record<string, string>;
|
initialFiles?: Record<string, string>;
|
||||||
tools?: { quixos: Source; protocol: Source; helpers: Source; sdk: Source };
|
controller?: { registry?: string; subject: string; snapshot: string; remote: string; input: "unit" | "value" };
|
||||||
nixifyPluginUrl?: string;
|
tools?: { quixos: Source; protocol: Source; helpers: Source; sdk?: Source };
|
||||||
migration?: Omit<MigrationDeclaration, "implementation"> & { contracts: Record<string, unknown> };
|
|
||||||
};
|
};
|
||||||
const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
|
|
||||||
const source = (value: Source): GitSource => {
|
const source = (value: Source): GitSource => {
|
||||||
if (!value || typeof value.repository !== "string" || typeof value.commit !== "string")
|
if (!value || typeof value.repository !== "string" || typeof value.commit !== "string")
|
||||||
throw new Error("Scaffold requires an exact source identity; use qx-workspace from a registered repository");
|
throw Error("Scaffold requires an exact source identity; use a registered repository");
|
||||||
const url = new URL(value.repository);
|
const url = new URL(value.repository);
|
||||||
if (
|
if (
|
||||||
url.protocol !== "https:" ||
|
url.protocol !== "https:" ||
|
||||||
@@ -47,375 +30,36 @@ const source = (value: Source): GitSource => {
|
|||||||
url.hash ||
|
url.hash ||
|
||||||
!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value.commit)
|
!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value.commit)
|
||||||
)
|
)
|
||||||
throw new Error("Scaffolds require credential-free HTTPS sources and full exact commits");
|
throw Error("Scaffolds require credential-free HTTPS sources and full exact commits");
|
||||||
return { resolver: "git", ...value };
|
return { resolver: "git", ...value };
|
||||||
};
|
};
|
||||||
const nixSource = (value: Source) =>
|
const nixSource = (value: Source) =>
|
||||||
`git+${source(value).repository}?ref=refs/tags/quixos-reachability/${value.commit}&rev=${value.commit}`;
|
`git+${source(value).repository}?ref=refs/tags/quixos-reachability/${value.commit}&rev=${value.commit}`;
|
||||||
const nixString = (value: string) => JSON.stringify(value).replaceAll("${", "\\${");
|
/** One-shot edits; structural planning retains interrupted work and authored files. */
|
||||||
const safeName = (name: string | undefined): string => {
|
export async function scaffoldRecipe(
|
||||||
if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error("Scaffold requires a simple authored name");
|
|
||||||
return name;
|
|
||||||
};
|
|
||||||
const ownedJson = async <T>(root: string, file: string): Promise<T> => {
|
|
||||||
const target = path.join(root, file);
|
|
||||||
if (!(await fs.realpath(target)).startsWith(`${await fs.realpath(root)}/`))
|
|
||||||
throw new Error("Scaffold input escapes repository");
|
|
||||||
const value = JSON.parse(await fs.readFile(target, "utf8"));
|
|
||||||
if (value.generatedBy !== "qx-scaffold-v1") throw new Error(`Not scaffold-owned: ${file}`);
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Recipes describe structural edits; planStructure owns validation/journaling.
|
|
||||||
* Implementation files are created once; later edits preserve authored wiring. */
|
|
||||||
export const scaffoldRecipe = async (
|
|
||||||
root: string,
|
root: string,
|
||||||
command: "package" | "function" | "migration" | "refresh",
|
command: "package" | "function" | "component" | "controller" | "query" | "migration" | "refresh",
|
||||||
spec: ScaffoldRecipe,
|
spec: ScaffoldRecipe,
|
||||||
): Promise<StructuralRequest> => {
|
): Promise<StructuralRequest> {
|
||||||
if (command === "refresh")
|
if (command === "refresh")
|
||||||
throw new Error(
|
throw Error(
|
||||||
"Scaffold refresh has been removed. Edit declarations and typed server wiring directly, then run qx-workspace check. Use scaffold function to add a declaration and handler together.",
|
"Scaffold refresh is removed. Edit authored code and run check; add entries with scaffold function/component/query.",
|
||||||
|
);
|
||||||
|
if (command === "migration")
|
||||||
|
throw Error(
|
||||||
|
"Old-workspace migration scaffolding is retired. Current disposable workspaces do not require migration code.",
|
||||||
);
|
);
|
||||||
source(spec.source);
|
source(spec.source);
|
||||||
if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory))
|
if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory))
|
||||||
throw new Error("Scaffold directory must be contained");
|
throw Error("Scaffold directory must be contained");
|
||||||
const prefix = spec.directory ? `${spec.directory}/` : "";
|
if (spec.template && !["typescript-execution", "typescript-component"].includes(spec.template))
|
||||||
const files: StructuralRequest["files"] = [];
|
throw Error("Use typescript-execution or typescript-component; old server/source-string presets are retired");
|
||||||
const create = (file: string, content: string) => files.push({ file: prefix + file, create: content });
|
const prefix = spec.directory ? spec.directory + "/" : "";
|
||||||
const generated = (file: string, content: string) => files.push({ file: prefix + file, generated: content });
|
if (command !== "package" && !(await isExecutionScaffold(root, prefix)))
|
||||||
let packageModel: PackageEditModel;
|
throw Error("Scaffolding requires a checked execution package");
|
||||||
let catalog: MigrationCatalog & { generatedBy: "qx-scaffold-v1" };
|
if (spec.tools) Object.values(spec.tools).forEach(source);
|
||||||
if (command === "package") {
|
if (command === "controller") return scaffoldController(root, prefix, spec);
|
||||||
const name = safeName(spec.name);
|
if (command === "component") return scaffoldComponent(root, prefix, spec);
|
||||||
if (spec.template && !["typescript", "typescript-react"].includes(spec.template))
|
if (command === "query") return scaffoldQuery(root, prefix, spec);
|
||||||
throw new Error("Unknown package template");
|
return executionScaffold(root, command, spec, prefix, nixSource);
|
||||||
const react = spec.template === "typescript-react";
|
|
||||||
if (!spec.id || !spec.revision || !spec.tools)
|
|
||||||
throw new Error("Package scaffold requires id, revision, and exact quixos/protocol/helpers/sdk tool sources");
|
|
||||||
Object.values(spec.tools).forEach(source);
|
|
||||||
packageModel = { generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: [] };
|
|
||||||
catalog = { generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: [] };
|
|
||||||
if (react)
|
|
||||||
packageModel.exports.push({ name: "sourceGet", id: `export:${name}:source`, file: "src/impl/sourceGet.ts" });
|
|
||||||
create(
|
|
||||||
"package.qx",
|
|
||||||
`package ${name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n${react ? ` function sourceGet id ${JSON.stringify(`export:${name}:source`)} : unit -> string;\n` : ""}}\n`,
|
|
||||||
);
|
|
||||||
create("quixos.lock", formatQuixosLock({ formatVersion: 1, quixos: source(spec.tools.quixos), resources: [] }));
|
|
||||||
create(
|
|
||||||
"package.json",
|
|
||||||
json({
|
|
||||||
name: `@quixos/${name.toLowerCase()}`,
|
|
||||||
version: "0.1.0",
|
|
||||||
private: true,
|
|
||||||
type: "module",
|
|
||||||
packageManager: "yarn@4.18.0",
|
|
||||||
scripts: { build: "tsc -p tsconfig.json", typecheck: "tsc --noEmit" },
|
|
||||||
dependencies: {
|
|
||||||
"@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`,
|
|
||||||
},
|
|
||||||
devDependencies: {
|
|
||||||
"@types/node": "^24",
|
|
||||||
typescript: "^7.0.2",
|
|
||||||
...(react ? { react: "^18.3.1", "@types/react": "^18.3.12", esbuild: "^0.25.12" } : {}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
create(
|
|
||||||
"tsconfig.json",
|
|
||||||
json({
|
|
||||||
compilerOptions: {
|
|
||||||
target: "ES2023",
|
|
||||||
module: "NodeNext",
|
|
||||||
moduleResolution: "NodeNext",
|
|
||||||
strict: true,
|
|
||||||
types: ["node", ...(react ? ["react"] : [])],
|
|
||||||
outDir: "dist",
|
|
||||||
rootDir: "src",
|
|
||||||
skipLibCheck: true,
|
|
||||||
...(react ? { jsx: "react-jsx", esModuleInterop: true } : {}),
|
|
||||||
},
|
|
||||||
include: ["src/**/*.ts", "src/**/*.tsx"],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (react) {
|
|
||||||
create(
|
|
||||||
"src/component.tsx",
|
|
||||||
`// Add a checked props export, select it in quixos.check.json options.react,\n// then use its generated ReactResults type. Scaffold bundle does this for you.\nexport default function Component(_props: {camino: Record<string, never>; render: unknown; dispatch: (action: unknown) => void}) {\n return <section><h1>${name}</h1></section>;\n}\n`,
|
|
||||||
);
|
|
||||||
create(
|
|
||||||
"src/impl/sourceGet.ts",
|
|
||||||
`import componentSource from "../component.js?browser-source";\nimport type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["sourceGet"] = () => componentSource;\n`,
|
|
||||||
);
|
|
||||||
create(
|
|
||||||
"src/browser-assets.d.ts",
|
|
||||||
`declare module "*?browser-source" { const source: string; export default source; }\ndeclare module "*.css" {}\n`,
|
|
||||||
);
|
|
||||||
create("src/gen/web-studio-react-runtime.d.ts", reactPlatformTypes);
|
|
||||||
}
|
}
|
||||||
create(".gitignore", "node_modules/\ndist/\n.quixos/\nresult\n.yarn/install-state.gz\n");
|
|
||||||
create(
|
|
||||||
".yarnrc.yml",
|
|
||||||
`nodeLinker: node-modules\nenableScripts: true\nnpmMinimalAgeGate: 0\napprovedGitRepositories:\n - ${JSON.stringify(spec.tools.sdk.repository)}\nsupportedArchitectures:\n os: [current, linux]\n cpu: [current, x64, arm64]\n libc: [current, glibc]\n`,
|
|
||||||
);
|
|
||||||
const nixifyPluginUrl =
|
|
||||||
spec.nixifyPluginUrl ??
|
|
||||||
"https://gitea-external.egads.tutti.syntaxblitz.net/quixos/yarn-plugin-nixify-patched/raw/commit/4528fdd20b30d869262443b3f044549810e75fb8/dist/yarn-plugin-nixify.js";
|
|
||||||
const plugin = new URL(nixifyPluginUrl);
|
|
||||||
if (
|
|
||||||
plugin.protocol !== "https:" ||
|
|
||||||
plugin.username ||
|
|
||||||
plugin.password ||
|
|
||||||
plugin.search ||
|
|
||||||
plugin.hash ||
|
|
||||||
!/\/commit\/[a-f0-9]{40,64}\//.test(plugin.pathname)
|
|
||||||
)
|
|
||||||
throw new Error("Nixify plugin must have an exact credential-free HTTPS commit URL");
|
|
||||||
generated("quixos.toolchain.json", json({ generatedBy: "qx-scaffold-v1", nixifyPluginUrl }));
|
|
||||||
create(
|
|
||||||
"quixos.check.json",
|
|
||||||
json({
|
|
||||||
backend: "typescript",
|
|
||||||
bindingOutput: "src/gen/qx.ts",
|
|
||||||
...(react
|
|
||||||
? {
|
|
||||||
options: { react: { propsExports: [] } },
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
create(
|
|
||||||
"flake.nix",
|
|
||||||
`{
|
|
||||||
inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))};
|
|
||||||
inputs.nixpkgs.follows = "protocol/nixpkgs";
|
|
||||||
inputs.flake-utils.follows = "protocol/flake-utils";
|
|
||||||
inputs.helpers = { url = ${nixString(nixSource(spec.tools.helpers))}; flake = false; };
|
|
||||||
outputs = inputs@{ self, protocol, nixpkgs, flake-utils, helpers, ... }:
|
|
||||||
(import (toString helpers + "/quixos-package-helpers.nix")).mkCaminoTsYarnNixifyFlake {
|
|
||||||
inherit inputs nixpkgs flake-utils; packageRoot = ./.;
|
|
||||||
bundle = { entry = ${JSON.stringify(react ? "src/server.ts" : "dist/server.js")}; ${react ? "browserSources = true;" : ""} };
|
|
||||||
migrationEntrypoint = "dist/migrate.js";
|
|
||||||
installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; };
|
|
||||||
};
|
|
||||||
}\n`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
catalog = await ownedJson<typeof catalog>(root, prefix + "quixos.migrations.json");
|
|
||||||
// Derive the edit model from authored declarations; it is never persisted.
|
|
||||||
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
|
|
||||||
const syntax = parseQx(authored);
|
|
||||||
if (syntax.diagnostics.length)
|
|
||||||
throw new Error("Cannot scaffold into an invalid package.qx; fix the reported syntax first");
|
|
||||||
const declaration = [...walkSyntax(syntax.root)].find((node) => node.kind === "packageResourceDecl");
|
|
||||||
if (!declaration) throw new Error("Expected a package declaration");
|
|
||||||
const text = (node: typeof declaration) => authored.slice(node.start, node.end);
|
|
||||||
const literals = declaration.children.filter((node) => node.kind === "stringLiteral");
|
|
||||||
packageModel = {
|
|
||||||
generatedBy: "qx-scaffold-v1",
|
|
||||||
name: text(declaration.children.find((node) => node.kind === "identifier")!),
|
|
||||||
id: JSON.parse(text(literals[0])),
|
|
||||||
revision: JSON.parse(text(literals[1])),
|
|
||||||
exports: [],
|
|
||||||
};
|
|
||||||
for (const node of walkSyntax(declaration)) {
|
|
||||||
if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind))
|
|
||||||
continue;
|
|
||||||
const name = safeName(text(node.children.find((child) => child.kind === "identifier")!));
|
|
||||||
const id = JSON.parse(text(node.children.find((child) => child.kind === "stringLiteral")!));
|
|
||||||
if (packageModel.exports.some((entry) => entry.id === id || entry.name === name))
|
|
||||||
throw new Error("Duplicate package export name or ID");
|
|
||||||
const migration = catalog.migrations.find((entry) => entry.implementation.exportId === id);
|
|
||||||
packageModel.exports.push({
|
|
||||||
name,
|
|
||||||
id,
|
|
||||||
file: migration?.implementation.file ?? `src/impl/${name}.ts`,
|
|
||||||
...(migration ? { migration: true } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
{
|
|
||||||
const name = safeName(spec.name);
|
|
||||||
if (!spec.id || packageModel.exports.some((entry) => entry.id === spec.id || entry.name === name))
|
|
||||||
throw new Error("New export requires a unique name and ID");
|
|
||||||
const declaration = spec.declaration ?? `function ${name} id ${JSON.stringify(spec.id)} : unit -> unit;`;
|
|
||||||
const parsed = parseQx(`package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`);
|
|
||||||
const exports = [...walkSyntax(parsed.root)].filter((node) =>
|
|
||||||
["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind),
|
|
||||||
);
|
|
||||||
if (parsed.diagnostics.length || exports.length !== 1)
|
|
||||||
throw new Error("Expected one valid package export declaration");
|
|
||||||
const wrapped = `package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`;
|
|
||||||
const node = exports[0];
|
|
||||||
const derived = [...walkSyntax(node)].some((entry) => entry.kind === "eventClause");
|
|
||||||
if (command === "migration" && (node.kind !== "packageFunctionExport" || spec.declaration))
|
|
||||||
throw new Error(
|
|
||||||
"Migration exports use the scaffold's unit function declaration and dedicated migration entrypoint",
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
wrapped.slice(
|
|
||||||
node.children.find((child) => child.kind === "identifier")!.start,
|
|
||||||
node.children.find((child) => child.kind === "identifier")!.end,
|
|
||||||
) !== name ||
|
|
||||||
JSON.parse(
|
|
||||||
wrapped.slice(
|
|
||||||
node.children.find((child) => child.kind === "stringLiteral")!.start,
|
|
||||||
node.children.find((child) => child.kind === "stringLiteral")!.end,
|
|
||||||
),
|
|
||||||
) !== spec.id
|
|
||||||
)
|
|
||||||
throw new Error("Declaration name/ID must match its registration");
|
|
||||||
files.push({
|
|
||||||
file: prefix + "package.qx",
|
|
||||||
edits: [
|
|
||||||
{ operation: "append", parent: { kind: "packageResourceDecl", id: packageModel.id }, source: declaration },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const file = `src/${command === "migration" ? "migrations" : "impl"}/${name}.ts`;
|
|
||||||
const implementation =
|
|
||||||
command === "migration"
|
|
||||||
? `import type {MigrationContext} from "@quixos/camino-package-runtime";\nexport const handler = async (_context: MigrationContext): Promise<void> => { throw new Error(${JSON.stringify(`Implement migration ${name}`)}); };\n`
|
|
||||||
: `import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation[${JSON.stringify(name)}] = ${derived ? '{kind: "derived", get: ' : ""}async (_context) => { throw new Error(${JSON.stringify(`Implement ${name}`)}); }${derived ? "}" : ""};\n`;
|
|
||||||
create(file, implementation);
|
|
||||||
packageModel.exports.push({ name, id: spec.id, file, ...(command === "migration" ? { migration: true } : {}) });
|
|
||||||
if (command === "migration") {
|
|
||||||
if (!spec.migration)
|
|
||||||
throw new Error("Migration scaffold requires retained contracts and an explicit transition");
|
|
||||||
const { contracts, ...transition } = spec.migration;
|
|
||||||
for (const [digest, contract] of Object.entries(contracts)) {
|
|
||||||
if (
|
|
||||||
contentDigest(contract) !== digest ||
|
|
||||||
(catalog.contracts[digest] && contentDigest(catalog.contracts[digest]) !== digest)
|
|
||||||
)
|
|
||||||
throw new Error("Retained migration contract mismatch");
|
|
||||||
catalog.contracts[digest] = contract;
|
|
||||||
}
|
|
||||||
catalog.migrations.push({
|
|
||||||
...transition,
|
|
||||||
implementation: { exportId: spec.id, file, digest: contentDigest(implementation) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
validateMigrationCatalog(catalog, new Set(packageModel.exports.map((entry) => entry.id)));
|
|
||||||
generated("quixos.migrations.json", json(catalog));
|
|
||||||
if (command !== "package") {
|
|
||||||
const entry = packageModel.exports.at(-1)!;
|
|
||||||
const file = prefix + "src/server.ts";
|
|
||||||
const before = await fs.readFile(path.join(root, file), "utf8");
|
|
||||||
files.push({
|
|
||||||
file,
|
|
||||||
expected: before,
|
|
||||||
replace: addImplementation(
|
|
||||||
before,
|
|
||||||
"createRuntime",
|
|
||||||
entry.name,
|
|
||||||
`./${entry.file.slice(4, -3)}.js`,
|
|
||||||
!!entry.migration,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (entry.migration) {
|
|
||||||
const file = prefix + "src/migrate.ts";
|
|
||||||
const before = await fs.readFile(path.join(root, file), "utf8");
|
|
||||||
files.push({
|
|
||||||
file,
|
|
||||||
expected: before,
|
|
||||||
replace: addImplementation(before, "serveMigration", entry.id, `./${entry.file.slice(4, -3)}.js`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
create(
|
|
||||||
"src/server.ts",
|
|
||||||
`import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./gen/qx.js";\n` +
|
|
||||||
packageModel.exports
|
|
||||||
.filter((entry) => !entry.migration)
|
|
||||||
.map(
|
|
||||||
(entry, index) =>
|
|
||||||
`import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`,
|
|
||||||
)
|
|
||||||
.join("") +
|
|
||||||
`servePackageRuntime(createRuntime({\n` +
|
|
||||||
packageModel.exports
|
|
||||||
.map(
|
|
||||||
(entry) =>
|
|
||||||
` ${JSON.stringify(entry.name)}: ${entry.migration ? 'async () => { throw new Error("Migration-only export"); }' : `impl${packageModel.exports.filter((value) => !value.migration).indexOf(entry)}`},`,
|
|
||||||
)
|
|
||||||
.join("\n") +
|
|
||||||
`\n}));\n`,
|
|
||||||
);
|
|
||||||
const migrations = packageModel.exports.filter((entry) => entry.migration);
|
|
||||||
create(
|
|
||||||
"src/migrate.ts",
|
|
||||||
`import {serveMigration} from "@quixos/camino-package-runtime";\n` +
|
|
||||||
migrations
|
|
||||||
.map(
|
|
||||||
(entry, index) =>
|
|
||||||
`import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`,
|
|
||||||
)
|
|
||||||
.join("") +
|
|
||||||
`await serveMigration({${migrations.map((entry, index) => `${JSON.stringify(entry.id)}: impl${index}`).join(", ")}});\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
generated(
|
|
||||||
"descriptor.quixos-package.txtpb",
|
|
||||||
`# Generated by qx-scaffold-v1\npackage_id: ${JSON.stringify(packageModel.id)}\npackage_revision_id: ${JSON.stringify(packageModel.revision)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` +
|
|
||||||
packageModel.exports
|
|
||||||
.map(
|
|
||||||
(entry) =>
|
|
||||||
`exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.name)} }\n`,
|
|
||||||
)
|
|
||||||
.join(""),
|
|
||||||
);
|
|
||||||
if (spec.initialFiles) {
|
|
||||||
if (command !== "package") throw new Error("initialFiles is only valid when creating a package");
|
|
||||||
const authored = spec.initialFiles["package.qx"];
|
|
||||||
if (authored !== undefined) {
|
|
||||||
const parsed = parseQx(authored);
|
|
||||||
const declaration = [...walkSyntax(parsed.root)].find((n) => n.kind === "packageResourceDecl");
|
|
||||||
const literals = declaration?.children.filter((n) => n.kind === "stringLiteral") ?? [];
|
|
||||||
const identifier = declaration?.children.find((n) => n.kind === "identifier");
|
|
||||||
if (
|
|
||||||
parsed.diagnostics.length ||
|
|
||||||
literals.length !== 2 ||
|
|
||||||
!identifier ||
|
|
||||||
authored.slice(identifier.start, identifier.end) !== spec.name ||
|
|
||||||
JSON.parse(authored.slice(literals[0].start, literals[0].end)) !== spec.id ||
|
|
||||||
JSON.parse(authored.slice(literals[1].start, literals[1].end)) !== spec.revision
|
|
||||||
)
|
|
||||||
throw new Error("Initial package declaration must match the provisioned name, ID and revision");
|
|
||||||
}
|
|
||||||
for (const [file, content] of Object.entries(spec.initialFiles)) {
|
|
||||||
if (
|
|
||||||
typeof content !== "string" ||
|
|
||||||
["quixos.lock", "flake.nix", "quixos.toolchain.json", "package.json"].includes(file)
|
|
||||||
)
|
|
||||||
throw new Error(`Not an initial authored file: ${file}`);
|
|
||||||
if (file === "quixos.check.json") {
|
|
||||||
const check = JSON.parse(content);
|
|
||||||
if (
|
|
||||||
check.backend !== "typescript" ||
|
|
||||||
check.bindingOutput !== "src/gen/qx.ts" ||
|
|
||||||
Object.keys(check).some((key) => !["backend", "bindingOutput", "options"].includes(key))
|
|
||||||
)
|
|
||||||
throw new Error(
|
|
||||||
"Initial check configuration may customize binding options, not the scaffold verification backend or output",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const existing = files.findIndex((entry) => entry.file === prefix + file);
|
|
||||||
if (existing >= 0) files.splice(existing, 1);
|
|
||||||
create(file, content);
|
|
||||||
}
|
|
||||||
// A composed React recipe supplies its own modules and complete server.
|
|
||||||
if (reactRecipe(spec)) {
|
|
||||||
for (const file of ["src/component.tsx", "src/impl/sourceGet.ts", "descriptor.quixos-package.txtpb"])
|
|
||||||
if (!(file in spec.initialFiles)) {
|
|
||||||
const index = files.findIndex((entry) => entry.file === prefix + file);
|
|
||||||
if (index >= 0) files.splice(index, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files };
|
|
||||||
};
|
|
||||||
|
|
||||||
const reactRecipe = (spec: ScaffoldRecipe) =>
|
|
||||||
spec.template === "typescript-react" && spec.initialFiles?.["package.qx"] && spec.initialFiles?.["src/server.ts"];
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import { lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { retentionTagForCommit } from "../resource-lock/index.js";
|
||||||
|
import { materializeWorkspaceSources, workspaceSourcePin, type WorkspaceSourcePin } from "./source-closure.js";
|
||||||
|
|
||||||
|
const exec = promisify(callback);
|
||||||
|
type SourceBackup = {
|
||||||
|
formatVersion: 1;
|
||||||
|
root: WorkspaceSourcePin;
|
||||||
|
repositories: Array<{ repository: string; commits: string[]; bundle: string; sha256: string }>;
|
||||||
|
};
|
||||||
|
async function hash(filename: string) {
|
||||||
|
const digest = createHash("sha256");
|
||||||
|
for await (const chunk of createReadStream(filename)) digest.update(chunk);
|
||||||
|
return digest.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Source-only backup: no Graph state, checkout instructions or credentials.
|
||||||
|
* Git bundles retain exact commits and their ancestors without needing a forge.
|
||||||
|
* Storage/retention of the resulting directory belongs to the operator. */
|
||||||
|
export async function backupWorkspaceSources(options: {
|
||||||
|
source: WorkspaceSourcePin;
|
||||||
|
directory: string;
|
||||||
|
output: string;
|
||||||
|
locations?: Record<string, string>;
|
||||||
|
}): Promise<SourceBackup> {
|
||||||
|
const output = path.resolve(options.output);
|
||||||
|
if (
|
||||||
|
await lstat(output).catch((error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
throw Error("Source backup destination must not exist");
|
||||||
|
const closure = await materializeWorkspaceSources(options);
|
||||||
|
const grouped = new Map<string, Map<string, string>>();
|
||||||
|
for (const entry of closure.resources) {
|
||||||
|
const commits = grouped.get(entry.repository) ?? new Map<string, string>();
|
||||||
|
commits.set(entry.commit, path.join(path.resolve(options.directory), entry.directory));
|
||||||
|
grouped.set(entry.repository, commits);
|
||||||
|
}
|
||||||
|
await mkdir(path.dirname(output), { recursive: true });
|
||||||
|
const staging = await mkdtemp(path.join(path.dirname(output), ".source-backup-"));
|
||||||
|
const manifest: SourceBackup = { formatVersion: 1, root: closure.root, repositories: [] };
|
||||||
|
try {
|
||||||
|
for (const [repository, commits] of [...grouped].sort(([a], [b]) => a.localeCompare(b))) {
|
||||||
|
const bundle = createHash("sha256").update(repository).digest("hex") + ".bundle";
|
||||||
|
const gitDirectory = await mkdtemp(path.join(staging, ".git-"));
|
||||||
|
try {
|
||||||
|
await exec("git", ["init", "--bare", gitDirectory]);
|
||||||
|
const refs: string[] = [];
|
||||||
|
for (const [commit, checkout] of commits) {
|
||||||
|
const ref = retentionTagForCommit(commit);
|
||||||
|
await exec("git", ["-C", gitDirectory, "fetch", "--no-tags", "--", checkout, `${ref}:${ref}`]);
|
||||||
|
refs.push(ref);
|
||||||
|
}
|
||||||
|
await exec("git", ["-C", gitDirectory, "bundle", "create", path.join(staging, bundle), ...refs]);
|
||||||
|
} finally {
|
||||||
|
await rm(gitDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
manifest.repositories.push({
|
||||||
|
repository,
|
||||||
|
commits: [...commits.keys()].sort(),
|
||||||
|
bundle,
|
||||||
|
sha256: await hash(path.join(staging, bundle)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await writeFile(path.join(staging, "backup.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 });
|
||||||
|
await rename(staging, output);
|
||||||
|
return manifest;
|
||||||
|
} finally {
|
||||||
|
await rm(staging, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreWorkspaceSources(options: { backup: string; directory: string }) {
|
||||||
|
const backup = path.resolve(options.backup);
|
||||||
|
const filename = path.join(backup, "backup.json");
|
||||||
|
const info = await lstat(filename);
|
||||||
|
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw Error("Invalid source backup manifest");
|
||||||
|
const manifest = JSON.parse(await readFile(filename, "utf8")) as SourceBackup;
|
||||||
|
if (
|
||||||
|
manifest.formatVersion !== 1 ||
|
||||||
|
!Array.isArray(manifest.repositories) ||
|
||||||
|
!manifest.repositories.length ||
|
||||||
|
manifest.repositories.length > 256
|
||||||
|
)
|
||||||
|
throw Error("Invalid source backup manifest");
|
||||||
|
const root = workspaceSourcePin(manifest.root);
|
||||||
|
const locations: Record<string, string> = {};
|
||||||
|
for (const entry of manifest.repositories) {
|
||||||
|
if (!entry || !Array.isArray(entry.commits) || !entry.commits.length || entry.commits.length > 256)
|
||||||
|
throw Error("Invalid source backup entry");
|
||||||
|
for (const commit of entry.commits) workspaceSourcePin({ repository: entry.repository, commit });
|
||||||
|
if (Object.hasOwn(locations, entry.repository)) throw Error("Duplicate source backup repository");
|
||||||
|
const expected = createHash("sha256").update(entry.repository).digest("hex") + ".bundle";
|
||||||
|
if (entry.bundle !== expected || !/^[a-f0-9]{64}$/.test(entry.sha256))
|
||||||
|
throw Error("Invalid source backup bundle identity");
|
||||||
|
const bundle = path.join(backup, expected);
|
||||||
|
const stat = await lstat(bundle);
|
||||||
|
if (!stat.isFile() || stat.isSymbolicLink() || (await hash(bundle)) !== entry.sha256)
|
||||||
|
throw Error("Source backup bundle integrity mismatch");
|
||||||
|
locations[entry.repository] = pathToFileURL(bundle).href;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!manifest.repositories.some((entry) => entry.repository === root.repository && entry.commits.includes(root.commit))
|
||||||
|
)
|
||||||
|
throw Error("Source backup does not retain its root commit");
|
||||||
|
// Every discovered dependency must use the backup. Never fall through to the
|
||||||
|
// original forge if a manifest omits a required repository.
|
||||||
|
return materializeWorkspaceSources({ source: root, directory: options.directory, locations, requireLocations: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { buildImmutableCandidate } from "./checked-build.js";
|
||||||
|
|
||||||
|
// Installed platform driver, run only inside the sealed checker's unprivileged
|
||||||
|
// evaluator. Authored flakes never become the outer evaluator program.
|
||||||
|
const [repository, commit, log, workspaceId] = process.argv.slice(2);
|
||||||
|
if (!repository || !commit || !log || !workspaceId || process.argv.length !== 6)
|
||||||
|
throw Error("source-check-driver REPOSITORY COMMIT LOG WORKSPACE_ID");
|
||||||
|
process.stdout.write(
|
||||||
|
(await buildImmutableCandidate(
|
||||||
|
{ repository, commit },
|
||||||
|
"workspace",
|
||||||
|
log,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
workspaceId,
|
||||||
|
process.env.QUIXOS_CHECK_PLATFORM_SOURCE ? JSON.parse(process.env.QUIXOS_CHECK_PLATFORM_SOURCE) : undefined,
|
||||||
|
)) + "\n",
|
||||||
|
);
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { mkdir, mkdtemp, lstat, realpath, rename, rm, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { loadQuixosLock, retentionTagForCommit } from "../resource-lock/index.js";
|
||||||
|
|
||||||
|
const exec = promisify(callback);
|
||||||
|
import { workspaceSourcePin, type WorkspaceSourcePin } from "./source-pin.js";
|
||||||
|
export { workspaceSourcePin, type WorkspaceSourcePin } from "./source-pin.js";
|
||||||
|
export type SourceClosureEntry = WorkspaceSourcePin & {
|
||||||
|
kind: "workspace" | "interface" | "package" | "build";
|
||||||
|
directory: string;
|
||||||
|
};
|
||||||
|
export type WorkspaceSourceClosure = {
|
||||||
|
formatVersion: 1;
|
||||||
|
root: WorkspaceSourcePin;
|
||||||
|
resources: SourceClosureEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Trusted transport substitutions preserve source identity. A mirror or bundle
|
||||||
|
* is explicitly selected, never tried automatically after an origin failure.
|
||||||
|
* Git authentication stays in the caller's Git configuration, outside metadata.
|
||||||
|
*/
|
||||||
|
export async function materializeWorkspaceSources(options: {
|
||||||
|
source: WorkspaceSourcePin;
|
||||||
|
directory: string;
|
||||||
|
locations?: Record<string, string>;
|
||||||
|
concurrency?: number;
|
||||||
|
maxRepositories?: number;
|
||||||
|
requireLocations?: boolean;
|
||||||
|
/** Optional acquisition boundary, enforced before fetching each dependency.
|
||||||
|
* It limits canonical identity; explicit transport substitutions remain a
|
||||||
|
* separate trusted operator decision. */
|
||||||
|
repositoryPrefix?: string;
|
||||||
|
}): Promise<WorkspaceSourceClosure> {
|
||||||
|
const source = workspaceSourcePin(options.source);
|
||||||
|
const prefix = options.repositoryPrefix ? new URL(options.repositoryPrefix) : null;
|
||||||
|
if (
|
||||||
|
prefix &&
|
||||||
|
(prefix.protocol !== "https:" ||
|
||||||
|
prefix.username ||
|
||||||
|
prefix.password ||
|
||||||
|
prefix.search ||
|
||||||
|
prefix.hash ||
|
||||||
|
!prefix.pathname.endsWith("/") ||
|
||||||
|
/[%\\\\]/.test(prefix.pathname))
|
||||||
|
)
|
||||||
|
throw Error("Source repository prefix requires a credential-free HTTPS directory URL");
|
||||||
|
const concurrency = options.concurrency ?? 4,
|
||||||
|
maxRepositories = options.maxRepositories ?? 256;
|
||||||
|
if (
|
||||||
|
!Number.isInteger(concurrency) ||
|
||||||
|
concurrency < 1 ||
|
||||||
|
concurrency > 16 ||
|
||||||
|
!Number.isInteger(maxRepositories) ||
|
||||||
|
maxRepositories < 1 ||
|
||||||
|
maxRepositories > 4096
|
||||||
|
)
|
||||||
|
throw Error("Invalid source closure limits");
|
||||||
|
const locations = new Map(Object.entries(options.locations ?? {}));
|
||||||
|
for (const [canonical, location] of locations) {
|
||||||
|
workspaceSourcePin({ repository: canonical, commit: source.commit });
|
||||||
|
const url = new URL(location);
|
||||||
|
if (
|
||||||
|
!["https:", "file:"].includes(url.protocol) ||
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
url.search ||
|
||||||
|
url.hash ||
|
||||||
|
(url.protocol === "file:" && url.hostname !== "")
|
||||||
|
)
|
||||||
|
throw Error("Invalid source transport substitution");
|
||||||
|
}
|
||||||
|
await mkdir(options.directory, { recursive: true, mode: 0o700 });
|
||||||
|
const directory = await realpath(options.directory);
|
||||||
|
const entries = new Map<string, SourceClosureEntry>();
|
||||||
|
const pending: SourceClosureEntry[] = [];
|
||||||
|
const enqueue = (pin: WorkspaceSourcePin, kind: SourceClosureEntry["kind"]) => {
|
||||||
|
pin = workspaceSourcePin(pin);
|
||||||
|
const repository = new URL(pin.repository);
|
||||||
|
if (
|
||||||
|
prefix &&
|
||||||
|
(repository.origin !== prefix.origin ||
|
||||||
|
!repository.pathname.startsWith(prefix.pathname) ||
|
||||||
|
/[%\\\\]/.test(repository.pathname))
|
||||||
|
)
|
||||||
|
throw Error(`Source repository is outside the acquisition boundary: ${pin.repository}`);
|
||||||
|
if (options.requireLocations && !locations.has(pin.repository))
|
||||||
|
throw Error(`Source backup omits required repository: ${pin.repository}`);
|
||||||
|
const key = `${kind}\0${pin.repository}\0${pin.commit}`;
|
||||||
|
if (entries.has(key)) return;
|
||||||
|
if (entries.size >= maxRepositories) throw Error("Workspace source closure exceeds repository limit");
|
||||||
|
const name = `${kind}-${createHash("sha256").update(key).digest("hex").slice(0, 24)}`;
|
||||||
|
const entry = { ...pin, kind, directory: name };
|
||||||
|
entries.set(key, entry);
|
||||||
|
pending.push(entry);
|
||||||
|
};
|
||||||
|
enqueue(source, "workspace");
|
||||||
|
const git = async (cwd: string, args: string[]) =>
|
||||||
|
(
|
||||||
|
await exec("git", args, {
|
||||||
|
cwd,
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
||||||
|
maxBuffer: 4 * 1024 * 1024,
|
||||||
|
})
|
||||||
|
).stdout.trim();
|
||||||
|
const checkout = async (entry: SourceClosureEntry) => {
|
||||||
|
const destination = path.join(directory, entry.directory);
|
||||||
|
const verify = async () => {
|
||||||
|
const info = await lstat(destination);
|
||||||
|
if (!info.isDirectory() || info.isSymbolicLink() || (await realpath(destination)) !== destination)
|
||||||
|
throw Error("Source checkout must be a contained directory");
|
||||||
|
if ((await git(destination, ["rev-parse", "HEAD"])) !== entry.commit)
|
||||||
|
throw Error(`Source commit mismatch: ${entry.repository}`);
|
||||||
|
if (await git(destination, ["status", "--porcelain", "--untracked-files=all"]))
|
||||||
|
throw Error(`Source checkout was modified: ${entry.repository}`);
|
||||||
|
};
|
||||||
|
const existing = await lstat(destination).catch((error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
const staging = await mkdtemp(path.join(directory, ".fetch-"));
|
||||||
|
try {
|
||||||
|
await git(staging, ["init"]);
|
||||||
|
await git(staging, ["remote", "add", "origin", entry.repository]);
|
||||||
|
const ref = retentionTagForCommit(entry.commit);
|
||||||
|
// Full history supports normal jj development and independent bundles.
|
||||||
|
const location = locations.get(entry.repository) ?? entry.repository;
|
||||||
|
await git(staging, [
|
||||||
|
"fetch",
|
||||||
|
"--no-tags",
|
||||||
|
"--",
|
||||||
|
location.startsWith("file:") ? fileURLToPath(location) : location,
|
||||||
|
`${ref}:${ref}`,
|
||||||
|
]);
|
||||||
|
await git(staging, ["checkout", "--detach", entry.commit]);
|
||||||
|
try {
|
||||||
|
await rename(staging, destination);
|
||||||
|
} catch (error) {
|
||||||
|
if (!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(staging, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await verify();
|
||||||
|
const lock = await loadQuixosLock(path.join(destination, "quixos.lock"));
|
||||||
|
if (!lock.ok)
|
||||||
|
throw Error(`Invalid source lock in ${entry.repository}: ${lock.diagnostics.map((d) => d.message).join("; ")}`);
|
||||||
|
return lock.lock.resources;
|
||||||
|
};
|
||||||
|
// Bounded breadth-first batches resolve diamonds once and avoid recursive
|
||||||
|
// promise deadlocks. Completion order cannot affect the recorded closure.
|
||||||
|
while (pending.length) {
|
||||||
|
const batch = pending.splice(0, concurrency);
|
||||||
|
const results = await Promise.allSettled(batch.map(checkout));
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === "rejected") throw result.reason;
|
||||||
|
for (const resource of result.value) enqueue(resource.source, resource.kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const closure: WorkspaceSourceClosure = {
|
||||||
|
formatVersion: 1,
|
||||||
|
root: source,
|
||||||
|
resources: [...entries.values()].sort((a, b) =>
|
||||||
|
`${a.kind}:${a.repository}@${a.commit}`.localeCompare(`${b.kind}:${b.repository}@${b.commit}`),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const temporary = path.join(directory, `.closure-${randomUUID()}.json`);
|
||||||
|
await writeFile(temporary, JSON.stringify(closure, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
||||||
|
await rename(temporary, path.join(directory, "sources.json"));
|
||||||
|
return closure;
|
||||||
|
}
|
||||||
@@ -4,13 +4,20 @@ import { parseQx, validateQxImportPath, walkSyntax } from "./source.js";
|
|||||||
|
|
||||||
export const readQxSource = async (root: string, name: string) => {
|
export const readQxSource = async (root: string, name: string) => {
|
||||||
validateQxImportPath(name);
|
validateQxImportPath(name);
|
||||||
|
return readRepositorySource(root, name);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Refuse symlinks at every component, including the final source file. */
|
||||||
|
export const readRepositorySource = async (root: string, name: string) => {
|
||||||
|
if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*$/.test(name))
|
||||||
|
throw new Error(`Invalid repository-relative source path: ${name}`);
|
||||||
let current = path.resolve(root);
|
let current = path.resolve(root);
|
||||||
const segments = name.split("/");
|
const segments = name.split("/");
|
||||||
for (const [index, segment] of segments.entries()) {
|
for (const [index, segment] of segments.entries()) {
|
||||||
current = path.join(current, segment);
|
current = path.join(current, segment);
|
||||||
const stat = await lstat(current);
|
const stat = await lstat(current);
|
||||||
if (stat.isSymbolicLink() || (index === segments.length - 1 ? !stat.isFile() : !stat.isDirectory()))
|
if (stat.isSymbolicLink() || (index === segments.length - 1 ? !stat.isFile() : !stat.isDirectory()))
|
||||||
throw new Error(`QX imports must be ordinary files beneath ordinary directories: ${name}`);
|
throw new Error(`Sources must be ordinary files beneath ordinary directories: ${name}`);
|
||||||
}
|
}
|
||||||
return readFile(current, "utf8");
|
return readFile(current, "utf8");
|
||||||
};
|
};
|
||||||
@@ -79,3 +86,21 @@ export const resolveQxSources = async (read: (name: string) => Promise<string>,
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const loadQxSources = (root: string) => resolveQxSources((name) => readQxSource(root, name));
|
export const loadQxSources = (root: string) => resolveQxSources((name) => readQxSource(root, name));
|
||||||
|
|
||||||
|
/** The same validated closure used by compilation, for installation and pinning. */
|
||||||
|
export const qxSourceManifest = async (root: string) => {
|
||||||
|
const resolved = await loadQxSources(root);
|
||||||
|
const syntax = parseQx(resolved.source);
|
||||||
|
const imports = [...walkSyntax(syntax.root)]
|
||||||
|
.filter((node) => node.kind === "resourceImportDecl")
|
||||||
|
.map((node) => {
|
||||||
|
const name = node.children.find((child) => child.kind === "identifier")!;
|
||||||
|
const kind = syntax.tokens.some(
|
||||||
|
(token) => token.start >= node.start && token.end <= node.end && token.kind === "INTERFACE",
|
||||||
|
)
|
||||||
|
? ("interface" as const)
|
||||||
|
: ("package" as const);
|
||||||
|
return { kind, binding: resolved.source.slice(name.start, name.end) };
|
||||||
|
});
|
||||||
|
return { sourceFiles: resolved.sourceFiles, imports };
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { loadQuixosLock, parseQuixosLockDocument, resolveQuixosLock } from "../resource-lock/index.js";
|
||||||
|
import { editStructure } from "./structural-edits.js";
|
||||||
|
import { workspaceSourcePin, type WorkspaceSourcePin } from "./source-closure.js";
|
||||||
|
|
||||||
|
/** Inspect/rewrite the root and its declared imports only, using the same lock
|
||||||
|
* grammar as compilation. Return proposed file contents; publishing them and
|
||||||
|
* selecting a Graph remain the caller's separate responsibilities. */
|
||||||
|
export async function workspaceSourceLocks(directory: string, input: unknown = []) {
|
||||||
|
if (!Array.isArray(input) || input.length > 256) throw Error("Invalid source lock replacements");
|
||||||
|
const replacements = new Map<string, WorkspaceSourcePin>();
|
||||||
|
const key = (source: WorkspaceSourcePin) => `${source.repository}\0${source.commit}`;
|
||||||
|
for (const entry of input) {
|
||||||
|
if (!entry || typeof entry !== "object") throw Error("Invalid source lock replacement");
|
||||||
|
const from = workspaceSourcePin(entry.from),
|
||||||
|
to = workspaceSourcePin(entry.to);
|
||||||
|
if (replacements.has(key(from))) throw Error("Duplicate source lock replacement");
|
||||||
|
replacements.set(key(from), to);
|
||||||
|
}
|
||||||
|
const lock = await loadQuixosLock(path.join(directory, "quixos.lock"));
|
||||||
|
if (!lock.ok) throw Error(`Invalid source lock: ${lock.diagnostics.map((d) => d.message).join("; ")}`);
|
||||||
|
const files = new Map<string, string>();
|
||||||
|
for (const filename of lock.lock.sourceFiles!) {
|
||||||
|
let source = await readFile(path.join(directory, filename), "utf8");
|
||||||
|
const parsed = parseQuixosLockDocument(source, filename);
|
||||||
|
if (!parsed.ok) throw Error("Source lock changed during inspection");
|
||||||
|
for (const dependency of parsed.document.resources) {
|
||||||
|
const replacement = replacements.get(key(dependency.source));
|
||||||
|
if (replacement)
|
||||||
|
source = editStructure(source, {
|
||||||
|
operation: "dependency",
|
||||||
|
kind: dependency.kind,
|
||||||
|
name: dependency.binding,
|
||||||
|
source: replacement,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
files.set(filename, source);
|
||||||
|
}
|
||||||
|
const checked = await resolveQuixosLock(files.get("quixos.lock")!, async (filename) => {
|
||||||
|
const source = files.get(filename);
|
||||||
|
if (source === undefined) throw Error("Source lock import changed during inspection");
|
||||||
|
return source;
|
||||||
|
});
|
||||||
|
if (!checked.ok)
|
||||||
|
throw Error(`Invalid rewritten source locks: ${checked.diagnostics.map((d) => d.message).join("; ")}`);
|
||||||
|
return { lock: checked.lock, files: [...files].map(([path, source]) => ({ path, source })) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export type WorkspaceSourcePin = { repository: string; commit: string };
|
||||||
|
|
||||||
|
export function workspaceSourcePin(value: unknown): WorkspaceSourcePin {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("Invalid Workspace source pin");
|
||||||
|
const pin = value as WorkspaceSourcePin;
|
||||||
|
const url = new URL(pin.repository);
|
||||||
|
if (
|
||||||
|
url.protocol !== "https:" ||
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
url.search ||
|
||||||
|
url.hash ||
|
||||||
|
typeof pin.commit !== "string" ||
|
||||||
|
!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(pin.commit)
|
||||||
|
)
|
||||||
|
throw Error("Workspace source pins require credential-free HTTPS and an exact commit");
|
||||||
|
return { repository: pin.repository, commit: pin.commit };
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ export type StructuralEdit =
|
|||||||
| { operation: "quixos-pin"; source: { repository: string; commit: string } }
|
| { operation: "quixos-pin"; source: { repository: string; commit: string } }
|
||||||
| {
|
| {
|
||||||
operation: "dependency";
|
operation: "dependency";
|
||||||
kind: "interface" | "package";
|
kind: "interface" | "package" | "build";
|
||||||
name: string;
|
name: string;
|
||||||
source: { repository: string; commit: string } | null;
|
source: { repository: string; commit: string } | null;
|
||||||
};
|
};
|
||||||
@@ -29,6 +29,8 @@ const selectable = new Set([
|
|||||||
"packageResourceDecl",
|
"packageResourceDecl",
|
||||||
"atomDecl",
|
"atomDecl",
|
||||||
"valueMember",
|
"valueMember",
|
||||||
|
"moduleMember",
|
||||||
|
"moduleExport",
|
||||||
"relationshipMember",
|
"relationshipMember",
|
||||||
"operationMember",
|
"operationMember",
|
||||||
"packageOperationExport",
|
"packageOperationExport",
|
||||||
@@ -155,7 +157,7 @@ export const editStructure = (source: string, edit: StructuralEdit): string => {
|
|||||||
if (edit.operation === "dependency") {
|
if (edit.operation === "dependency") {
|
||||||
const parsed = parseQuixosLockDocument(source);
|
const parsed = parseQuixosLockDocument(source);
|
||||||
if (!parsed.ok) throw new Error("Cannot scaffold an invalid lockfile");
|
if (!parsed.ok) throw new Error("Cannot scaffold an invalid lockfile");
|
||||||
if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name))
|
if (!["interface", "package", "build"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name))
|
||||||
throw new Error("Invalid dependency selector");
|
throw new Error("Invalid dependency selector");
|
||||||
const parser = new QuixosLockParser(new CommonTokenStream(new QuixosLockLexer(CharStream.fromString(source))));
|
const parser = new QuixosLockParser(new CommonTokenStream(new QuixosLockLexer(CharStream.fromString(source))));
|
||||||
const tree = parser.document();
|
const tree = parser.document();
|
||||||
@@ -208,7 +210,8 @@ export const editStructure = (source: string, edit: StructuralEdit): string => {
|
|||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
const identifiers = node.children.filter((entry) => entry.kind === "identifier");
|
const identifiers = node.children.filter((entry) => entry.kind === "identifier");
|
||||||
const position = identifiers[identifiers.length - 1].end;
|
const targetArguments = node.children.find((entry) => entry.kind === "typeArguments");
|
||||||
|
const position = targetArguments?.end ?? identifiers[identifiers.length - 1].end;
|
||||||
result = applySourceEdits(source, [{ start: position, end: position, text: ` id ${JSON.stringify(edit.id)}` }]);
|
result = applySourceEdits(source, [{ start: position, end: position, text: ` id ${JSON.stringify(edit.id)}` }]);
|
||||||
} else if (edit.operation === "append") {
|
} else if (edit.operation === "append") {
|
||||||
const closing = syntax.tokens.find((token) => token.kind === "RBRACE" && token.end === node.end);
|
const closing = syntax.tokens.find((token) => token.kind === "RBRACE" && token.end === node.end);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { parseQuixosLockDocument } from "../resource-lock/index.js";
|
|||||||
import { withFileLock } from "./file-lock.js";
|
import { withFileLock } from "./file-lock.js";
|
||||||
|
|
||||||
export type StructuralRequest = {
|
export type StructuralRequest = {
|
||||||
kind: "workspace" | "interface" | "package";
|
kind: "workspace" | "interface" | "package" | "build";
|
||||||
source?: { repository: string; commit: string };
|
source?: { repository: string; commit: string };
|
||||||
resourceRoot?: string;
|
resourceRoot?: string;
|
||||||
validation?: "syntax" | "resource-graph";
|
validation?: "syntax" | "resource-graph";
|
||||||
@@ -28,7 +28,7 @@ type Change = { file: string; before: string | null; after: string; mode: number
|
|||||||
type Journal = { schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[] };
|
type Journal = { schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[] };
|
||||||
const safeFile = (file: string) => {
|
const safeFile = (file: string) => {
|
||||||
if (
|
if (
|
||||||
!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc.yml|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|lock|ts|tsx|css|mjs|json|nix|txtpb|md))$/.test(
|
!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc\.yml|\.yarn\/plugins\/[A-Za-z0-9_-][A-Za-z0-9_.-]*\.cjs|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|graphql|lock|ts|tsx|css|mjs|json|nix|txtpb|md))$/.test(
|
||||||
file,
|
file,
|
||||||
) ||
|
) ||
|
||||||
file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part))
|
file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part))
|
||||||
@@ -141,7 +141,7 @@ export const planStructure = async (rootPath: string, request: StructuralRequest
|
|||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
if (change.file.endsWith(".qx") && parseQx(change.after, change.file).diagnostics.length)
|
if (change.file.endsWith(".qx") && parseQx(change.after, change.file).diagnostics.length)
|
||||||
throw new Error(`Invalid QX syntax in ${change.file}`);
|
throw new Error(`Invalid QX syntax in ${change.file}`);
|
||||||
if (change.file.endsWith(".lock") && !parseQuixosLockDocument(change.after, change.file).ok)
|
if (path.basename(change.file) === "quixos.lock" && !parseQuixosLockDocument(change.after, change.file).ok)
|
||||||
throw new Error(`Invalid lock syntax in ${change.file}`);
|
throw new Error(`Invalid lock syntax in ${change.file}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -159,16 +159,22 @@ export const planStructure = async (rootPath: string, request: StructuralRequest
|
|||||||
const resourceRoot = path.join(snapshot.directory, request.resourceRoot ?? "");
|
const resourceRoot = path.join(snapshot.directory, request.resourceRoot ?? "");
|
||||||
if (request.kind === "workspace")
|
if (request.kind === "workspace")
|
||||||
await compileWorkspaceRepository({ rootDirectory: resourceRoot, resolveResource });
|
await compileWorkspaceRepository({ rootDirectory: resourceRoot, resolveResource });
|
||||||
else if (["package", "interface"].includes(request.kind) && request.source) {
|
else if (["package", "interface", "build"].includes(request.kind) && request.source) {
|
||||||
const compiled = await compileCapabilityResourceRepository({
|
const compiled = await compileCapabilityResourceRepository({
|
||||||
rootDirectory: resourceRoot,
|
rootDirectory: resourceRoot,
|
||||||
kind: request.kind as "package" | "interface",
|
kind: request.kind as "package" | "interface" | "build",
|
||||||
source: { resolver: "git", ...request.source },
|
source: { resolver: "git", ...request.source },
|
||||||
resolveResource,
|
resolveResource,
|
||||||
});
|
});
|
||||||
if (compiled.resource.kind === "package") {
|
if (compiled.resource.kind === "package") {
|
||||||
const configuration = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.check.json"), "utf8"));
|
const configurationSource = await read(resourceRoot, "quixos.check.json");
|
||||||
const artifacts = [
|
const configuration = configurationSource === null ? undefined : JSON.parse(configurationSource);
|
||||||
|
// Execution bindings are immutable build outputs owned by checkedArtifacts,
|
||||||
|
// not source edits produced by a structural preview.
|
||||||
|
const artifacts =
|
||||||
|
!configuration || configuration.backend === "execution"
|
||||||
|
? []
|
||||||
|
: [
|
||||||
{
|
{
|
||||||
file: configuration.bindingOutput as string,
|
file: configuration.bindingOutput as string,
|
||||||
after: generateTypeScriptBindings(
|
after: generateTypeScriptBindings(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { readFile, writeFile, mkdtemp, rm, realpath } from "node:fs/promises";
|
import { readFile, writeFile, mkdtemp, rm, realpath } from "node:fs/promises";
|
||||||
import { parseQx, formatQx, lintQx } from "./source.js";
|
import { parseQx, formatQx, lintQx } from "./source.js";
|
||||||
import { scaffoldAtom } from "./scaffold.js";
|
import { scaffoldAtom } from "./scaffold.js";
|
||||||
import { loadQxSources } from "./source-loader.js";
|
import { loadQxSources, qxSourceManifest } from "./source-loader.js";
|
||||||
import { createGitCapabilityResolver } from "./git-resolver.js";
|
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||||
import { planEvolution } from "../capability-model/index.js";
|
import { planEvolution } from "../capability-model/index.js";
|
||||||
import { snapshotRepository } from "./candidate-check.js";
|
import { snapshotRepository } from "./candidate-check.js";
|
||||||
@@ -54,6 +54,70 @@ const planSummary = (plan: Awaited<ReturnType<typeof planStructure>>) => ({
|
|||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
const [command, ...args] = process.argv.slice(2);
|
const [command, ...args] = process.argv.slice(2);
|
||||||
|
if (command === "source-locks") {
|
||||||
|
if (!args[0] || (args.length !== 1 && (args.length !== 3 || args[1] !== "--replacements")))
|
||||||
|
throw Error("source-locks DIRECTORY [--replacements JSON_FILE]");
|
||||||
|
const { workspaceSourceLocks } = await import("./source-locks.js");
|
||||||
|
const replacements = args[2] ? JSON.parse(await readFile(args[2], "utf8")) : [];
|
||||||
|
process.stdout.write(JSON.stringify(await workspaceSourceLocks(path.resolve(args[0]), replacements)) + "\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (["source-checkout", "source-develop", "source-backup", "source-restore"].includes(command!)) {
|
||||||
|
const args = ["source", command.slice("source-".length), ...process.argv.slice(3)];
|
||||||
|
const { materializeWorkspaceSources } = await import("./source-closure.js");
|
||||||
|
if (!args[2]) throw Error("source requires a pinned source JSON file");
|
||||||
|
const flags = new Map<string, string>();
|
||||||
|
for (let i = 3; i < args.length; i += 2) {
|
||||||
|
if (
|
||||||
|
![
|
||||||
|
"--dir",
|
||||||
|
...(args[1] !== "restore" ? ["--locations"] : []),
|
||||||
|
...(args[1] === "checkout" ? ["--repository-prefix"] : []),
|
||||||
|
...(args[1] === "develop" ? ["--cache", "--workspace-id"] : []),
|
||||||
|
...(args[1] === "backup" ? ["--output"] : []),
|
||||||
|
].includes(args[i]) ||
|
||||||
|
!args[i + 1] ||
|
||||||
|
flags.has(args[i])
|
||||||
|
)
|
||||||
|
throw Error("Invalid source command options; use qx-workspace help source");
|
||||||
|
flags.set(args[i], args[i + 1]);
|
||||||
|
}
|
||||||
|
if (!flags.get("--dir")) throw Error("source requires --dir DIRECTORY");
|
||||||
|
if (args[1] === "restore") {
|
||||||
|
const { restoreWorkspaceSources } = await import("./source-backup.js");
|
||||||
|
const result = await restoreWorkspaceSources({ backup: args[2], directory: path.resolve(flags.get("--dir")!) });
|
||||||
|
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const locationsFile = flags.get("--locations");
|
||||||
|
const options = {
|
||||||
|
source: JSON.parse(await readFile(args[2], "utf8")),
|
||||||
|
directory: path.resolve(flags.get("--dir")!),
|
||||||
|
...(locationsFile ? { locations: JSON.parse(await readFile(locationsFile, "utf8")) } : {}),
|
||||||
|
...(flags.has("--repository-prefix") ? { repositoryPrefix: flags.get("--repository-prefix") } : {}),
|
||||||
|
};
|
||||||
|
let result;
|
||||||
|
if (args[1] === "develop") {
|
||||||
|
if (!flags.get("--cache")) throw Error("source develop requires --cache DIRECTORY");
|
||||||
|
const { prepareDevelopmentSources } = await import("./development-sources.js");
|
||||||
|
result = await prepareDevelopmentSources({
|
||||||
|
...options,
|
||||||
|
cacheDirectory: path.resolve(flags.get("--cache")!),
|
||||||
|
...(flags.has("--workspace-id") ? { workspaceId: flags.get("--workspace-id")! } : {}),
|
||||||
|
});
|
||||||
|
} else if (args[1] === "backup") {
|
||||||
|
if (!flags.get("--output")) throw Error("source backup requires --output DIRECTORY");
|
||||||
|
const { backupWorkspaceSources } = await import("./source-backup.js");
|
||||||
|
result = await backupWorkspaceSources({ ...options, output: path.resolve(flags.get("--output")!) });
|
||||||
|
} else result = await materializeWorkspaceSources(options);
|
||||||
|
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "source-manifest") {
|
||||||
|
if (args.length !== 1) throw new Error("source-manifest WORKSPACE_ROOT");
|
||||||
|
process.stdout.write(JSON.stringify(await qxSourceManifest(args[0])) + "\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (command === "scaffold-validate-qx") {
|
if (command === "scaffold-validate-qx") {
|
||||||
if (args.length !== 1) throw new Error("scaffold-validate-qx SOURCES_JSON");
|
if (args.length !== 1) throw new Error("scaffold-validate-qx SOURCES_JSON");
|
||||||
const sources = JSON.parse(args[0]);
|
const sources = JSON.parse(args[0]);
|
||||||
@@ -192,16 +256,22 @@ const main = async () => {
|
|||||||
}
|
}
|
||||||
if (command === "check-committed" && !args.includes("--help")) {
|
if (command === "check-committed" && !args.includes("--help")) {
|
||||||
const [kind, repository, commit, log, ...extra] = args;
|
const [kind, repository, commit, log, ...extra] = args;
|
||||||
if (!["workspace", "interface", "package"].includes(kind) || !log || extra.length)
|
if (
|
||||||
throw new Error("usage: quixos-qx check-committed workspace|interface|package REPOSITORY COMMIT LOG_FILE");
|
!["workspace", "interface", "package", "build"].includes(kind) ||
|
||||||
|
!log ||
|
||||||
|
(extra.length !== 0 && (kind !== "workspace" || extra.length !== 2 || extra[0] !== "--workspace-id" || !extra[1]))
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"usage: quixos-qx check-committed workspace|interface|package|build REPOSITORY COMMIT LOG_FILE [--workspace-id ID]",
|
||||||
|
);
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`${await buildImmutableCandidate({ repository, commit }, kind as "workspace" | "interface" | "package", log)}\n`,
|
`${await buildImmutableCandidate({ repository, commit }, kind as "workspace" | "interface" | "package" | "build", log, false, undefined, extra[1])}\n`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!command || command === "--help" || args.includes("--help")) {
|
if (!command || command === "--help" || args.includes("--help")) {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
"quixos-qx: author-check, author-contract, converge, worklist, inspect, resources, check-committed, source-baseline, scaffold-package, scaffold-interface, scaffold-function, scaffold-dependency, scaffold-structure, scaffold-resume, pin-upgrade, parse, lint, format\n" +
|
"quixos-qx: author-check, author-contract, converge, worklist, inspect, resources, check-committed, source-baseline, scaffold-package, scaffold-interface, scaffold-build, scaffold-function, scaffold-query, scaffold-dependency, scaffold-structure, scaffold-resume, pin-upgrade, parse, lint, format\n" +
|
||||||
"inspect WORKBENCH [RESOURCE] shows provisional contracts, with explicit historical fallback; never verification evidence.\n" +
|
"inspect WORKBENCH [RESOURCE] shows provisional contracts, with explicit historical fallback; never verification evidence.\n" +
|
||||||
"resources WORKBENCH lists registered editable repositories. Use qx-workspace for the workspace authoring workflow.\n",
|
"resources WORKBENCH lists registered editable repositories. Use qx-workspace for the workspace authoring workflow.\n",
|
||||||
);
|
);
|
||||||
@@ -226,7 +296,7 @@ const main = async () => {
|
|||||||
if (flags.length && !flags[0].startsWith("--")) {
|
if (flags.length && !flags[0].startsWith("--")) {
|
||||||
repository = flags.shift();
|
repository = flags.shift();
|
||||||
commit = flags.shift();
|
commit = flags.shift();
|
||||||
} else if (root && ["interface", "package"].includes(kind) && name) {
|
} else if (root && ["interface", "package", "build"].includes(kind) && name) {
|
||||||
const context = await authoringContext(root);
|
const context = await authoringContext(root);
|
||||||
const alias = await realpath(path.join(context.workbench, `${kind}s`, name)).catch(() => null);
|
const alias = await realpath(path.join(context.workbench, `${kind}s`, name)).catch(() => null);
|
||||||
const matches = context.resources.filter(
|
const matches = context.resources.filter(
|
||||||
@@ -256,15 +326,17 @@ const main = async () => {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!root ||
|
!root ||
|
||||||
!["interface", "package"].includes(kind) ||
|
!["interface", "package", "build"].includes(kind) ||
|
||||||
!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name ?? "") ||
|
!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name ?? "") ||
|
||||||
!repository ||
|
!repository ||
|
||||||
!commit ||
|
!commit ||
|
||||||
flags.some((flag) => flag !== "--write")
|
flags.some((flag) => flag !== "--write")
|
||||||
)
|
)
|
||||||
throw new Error("usage: quixos-qx scaffold-dependency ROOT interface|package NAME REPOSITORY COMMIT [--write]");
|
throw new Error(
|
||||||
const resourceKind = kind as "package" | "interface";
|
"usage: quixos-qx scaffold-dependency ROOT interface|package|build NAME REPOSITORY COMMIT [--write]",
|
||||||
let entrypoint: "workspace" | "package" | "interface" | undefined;
|
);
|
||||||
|
const resourceKind = kind as "package" | "interface" | "build";
|
||||||
|
let entrypoint: "workspace" | "package" | "interface" | "build" | undefined;
|
||||||
for (const candidate of ["workspace", "package", "interface"] as const) {
|
for (const candidate of ["workspace", "package", "interface"] as const) {
|
||||||
try {
|
try {
|
||||||
await readFile(path.join(root, `${candidate}.qx`));
|
await readFile(path.join(root, `${candidate}.qx`));
|
||||||
@@ -274,7 +346,14 @@ const main = async () => {
|
|||||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!entrypoint) throw new Error("No QX repository entrypoint");
|
if (!entrypoint) {
|
||||||
|
const context = await authoringContext(root);
|
||||||
|
const selected = context.resources.find(
|
||||||
|
(entry) => path.join(context.workbench, entry.directory) === path.resolve(root),
|
||||||
|
);
|
||||||
|
if (selected?.kind !== "build") throw new Error("No QX repository entrypoint");
|
||||||
|
entrypoint = "build";
|
||||||
|
}
|
||||||
const lock = await loadQuixosLock(path.join(root, "quixos.lock"));
|
const lock = await loadQuixosLock(path.join(root, "quixos.lock"));
|
||||||
if (!lock.ok) throw new Error("Invalid resource lock");
|
if (!lock.ok) throw new Error("Invalid resource lock");
|
||||||
let target = "quixos.lock";
|
let target = "quixos.lock";
|
||||||
@@ -288,7 +367,9 @@ const main = async () => {
|
|||||||
source: await authorSource(root),
|
source: await authorSource(root),
|
||||||
validation: "syntax",
|
validation: "syntax",
|
||||||
files: [
|
files: [
|
||||||
{ file: `${entrypoint}.qx`, edits: [{ operation: "import", kind: resourceKind, name }] },
|
...(entrypoint !== "build" && resourceKind !== "build"
|
||||||
|
? [{ file: `${entrypoint}.qx`, edits: [{ operation: "import" as const, kind: resourceKind, name }] }]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
file: target,
|
file: target,
|
||||||
edits: [{ operation: "dependency", kind: resourceKind, name, source: { repository, commit } }],
|
edits: [{ operation: "dependency", kind: resourceKind, name, source: { repository, commit } }],
|
||||||
@@ -301,26 +382,45 @@ const main = async () => {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (command === "scaffold-interface") {
|
if (command === "scaffold-interface" || command === "scaffold-build") {
|
||||||
|
const isBuild = command === "scaffold-build";
|
||||||
const [root, specFile, ...flags] = args;
|
const [root, specFile, ...flags] = args;
|
||||||
if (!root || !specFile || flags.some((flag) => flag !== "--write"))
|
if (!root || !specFile || flags.some((flag) => flag !== "--write"))
|
||||||
throw new Error("usage: quixos-qx scaffold-interface ROOT SPEC_JSON [--write]");
|
throw new Error("usage: quixos-qx scaffold-interface|scaffold-build ROOT SPEC_JSON [--write]");
|
||||||
const spec = (await readSpec(specFile)) as ScaffoldRecipe;
|
const spec = (await readSpec(specFile)) as ScaffoldRecipe;
|
||||||
if (!spec.name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || !spec.id || !spec.revision || !spec.tools?.quixos)
|
if (
|
||||||
throw new Error("Interface scaffold requires name, id, revision and Quixos toolchain source");
|
!spec.name ||
|
||||||
|
!/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) ||
|
||||||
|
(!isBuild && (!spec.id || !spec.revision)) ||
|
||||||
|
!spec.tools?.quixos
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
isBuild
|
||||||
|
? "Build scaffold requires name and Quixos toolchain source"
|
||||||
|
: "Interface scaffold requires name, id, revision and Quixos toolchain source",
|
||||||
|
);
|
||||||
const request: StructuralRequest = {
|
const request: StructuralRequest = {
|
||||||
kind: "interface",
|
kind: isBuild ? "build" : "interface",
|
||||||
// Like package scaffolding, declaration creation is provisional. Imports
|
// Like package scaffolding, declaration creation is provisional. Imports
|
||||||
// can be attached next; the normal check verifies the complete graph.
|
// can be attached next; the normal check verifies the complete graph.
|
||||||
validation: "syntax",
|
validation: "syntax",
|
||||||
source: spec.source,
|
source: spec.source,
|
||||||
files: [
|
files: [
|
||||||
|
...(!isBuild
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
file: "interface.qx",
|
file: "interface.qx",
|
||||||
create:
|
create:
|
||||||
spec.declaration ??
|
spec.declaration ??
|
||||||
`interface ${spec.name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n}\n`,
|
`interface ${spec.name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n}\n`,
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
file: "README.md",
|
||||||
|
create: `# ${spec.name}\n\nManaged build dependency. Consumers select sources or Nix outputs through context.dependencies.\n`,
|
||||||
|
},
|
||||||
|
]),
|
||||||
{
|
{
|
||||||
file: "quixos.lock",
|
file: "quixos.lock",
|
||||||
create: formatQuixosLock({
|
create: formatQuixosLock({
|
||||||
@@ -385,9 +485,20 @@ const main = async () => {
|
|||||||
}
|
}
|
||||||
if (command === "scaffold-refresh")
|
if (command === "scaffold-refresh")
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Refresh is no longer required: edit declarations and typed implementation wiring, then run qx-workspace check. Dependency installation uses scaffold install.",
|
"Refresh is no longer required: edit declarations and typed implementation wiring, then run qx-workspace check. Dependencies are provided by checkedArtifacts.",
|
||||||
);
|
);
|
||||||
if (["scaffold-package", "scaffold-function", "scaffold-migration", "scaffold-install"].includes(command)) {
|
if (command === "scaffold-install")
|
||||||
|
throw Error("Execution dependencies are supplied by checkedArtifacts; run workspace-root check instead");
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"scaffold-package",
|
||||||
|
"scaffold-function",
|
||||||
|
"scaffold-query",
|
||||||
|
"scaffold-component",
|
||||||
|
"scaffold-controller",
|
||||||
|
"scaffold-migration",
|
||||||
|
].includes(command)
|
||||||
|
) {
|
||||||
const [root, specFile, ...flags] = args;
|
const [root, specFile, ...flags] = args;
|
||||||
let spec: ScaffoldRecipe;
|
let spec: ScaffoldRecipe;
|
||||||
if (command === "scaffold-function" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(specFile ?? "")) {
|
if (command === "scaffold-function" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(specFile ?? "")) {
|
||||||
@@ -420,46 +531,24 @@ const main = async () => {
|
|||||||
flags.some((flag) => !["--write", "--install"].includes(flag)) ||
|
flags.some((flag) => !["--write", "--install"].includes(flag)) ||
|
||||||
(flags.includes("--install") && !flags.includes("--write"))
|
(flags.includes("--install") && !flags.includes("--write"))
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error("usage: quixos-qx scaffold-package|function|query|component|controller ROOT SPEC_JSON [--write]");
|
||||||
"usage: quixos-qx scaffold-package|function|migration|refresh ROOT SPEC_JSON [--write [--install]]",
|
|
||||||
);
|
|
||||||
const plan =
|
const plan =
|
||||||
command === "scaffold-install"
|
command === "scaffold-install"
|
||||||
? undefined
|
? undefined
|
||||||
: await planStructure(
|
: await planStructure(
|
||||||
root,
|
root,
|
||||||
await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration", spec),
|
await scaffoldRecipe(
|
||||||
|
root,
|
||||||
|
command.slice(9) as "package" | "function" | "query" | "component" | "controller" | "migration",
|
||||||
|
spec,
|
||||||
|
),
|
||||||
process.env.QUIXOS_SNAPSHOT_MAP,
|
process.env.QUIXOS_SNAPSHOT_MAP,
|
||||||
);
|
);
|
||||||
|
if (flags.includes("--install"))
|
||||||
|
throw Error(
|
||||||
|
"Execution dependencies are supplied by checkedArtifacts; run workspace-root check instead of scaffold install",
|
||||||
|
);
|
||||||
const applied = plan && flags.includes("--write") ? await applyStructure(plan) : undefined;
|
const applied = plan && flags.includes("--write") ? await applyStructure(plan) : undefined;
|
||||||
if (flags.includes("--install")) {
|
|
||||||
const cwd = path.resolve(root, spec.directory ?? "");
|
|
||||||
const toolchain = JSON.parse(await readFile(path.join(cwd, "quixos.toolchain.json"), "utf8"));
|
|
||||||
if (toolchain.generatedBy !== "qx-scaffold-v1" || typeof toolchain.nixifyPluginUrl !== "string")
|
|
||||||
throw new Error("Missing scaffold toolchain");
|
|
||||||
for (const [executable, args] of [
|
|
||||||
["corepack", ["yarn", "plugin", "import", toolchain.nixifyPluginUrl]],
|
|
||||||
["corepack", ["yarn", "config", "set", "generateDefaultNix", "false"]],
|
|
||||||
["corepack", ["yarn", "config", "set", "individualNixPackaging", "true"]],
|
|
||||||
["corepack", ["yarn", "install"]],
|
|
||||||
] as const) {
|
|
||||||
const result = spawnSync(executable, [...args], { cwd, stdio: ["inherit", 2, 2] });
|
|
||||||
if (result.error || result.status !== 0)
|
|
||||||
throw new Error(
|
|
||||||
`Scaffold files retained; ${executable} ${args.join(" ")} failed: ${result.error?.message ?? result.status}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await readFile(path.join(cwd, "yarn-project.nix"));
|
|
||||||
} catch {
|
|
||||||
throw new Error(
|
|
||||||
"Nixify did not generate yarn-project.nix. It skips repositories under the OS temporary directory; use an ordinary workspace checkout and retry installation.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await snapshotCommit(cwd);
|
|
||||||
const locked = spawnSync("nix", ["flake", "lock"], { cwd, stdio: ["inherit", 2, 2] });
|
|
||||||
if (locked.error || locked.status !== 0) throw new Error("Scaffold files retained; nix flake lock failed");
|
|
||||||
}
|
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`${JSON.stringify({ ...(plan ? planSummary(plan) : { installed: true }), applied }, null, 2)}\n`,
|
`${JSON.stringify({ ...(plan ? planSummary(plan) : { installed: true }), applied }, null, 2)}\n`,
|
||||||
);
|
);
|
||||||
@@ -506,6 +595,30 @@ const main = async () => {
|
|||||||
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
|
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (command === "format" && (args[0] === "--check" || args[0] === "--write")) {
|
||||||
|
const check = args[0] === "--check",
|
||||||
|
files = args.slice(1);
|
||||||
|
if (!files.length || files.some((file) => file.startsWith("-")))
|
||||||
|
throw new Error("usage: quixos-qx format --check|--write FILE...");
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const source = await readFile(file, "utf8"),
|
||||||
|
formatted = formatQx(source);
|
||||||
|
if (formatted === source) continue;
|
||||||
|
if (check) {
|
||||||
|
console.error(`Needs formatting: ${file}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
await writeFile(file, formatted);
|
||||||
|
console.log(`Formatted ${file}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${file}: ${error instanceof Error ? error.message : error}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const [file, flag, ...rest] = args;
|
const [file, flag, ...rest] = args;
|
||||||
if (
|
if (
|
||||||
!file ||
|
!file ||
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { readFile, writeFile } from "node:fs/promises";
|
|||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { compileWorkspaceRepository } from "./assembly.js";
|
import { compileWorkspaceRepository } from "./assembly.js";
|
||||||
import { createGitCapabilityResolver } from "./git-resolver.js";
|
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||||
|
import { loadQueryGenerator } from "./query-generator.js";
|
||||||
|
import { loadCheckedResources } from "./checked-resources.js";
|
||||||
|
import { profile } from "./profile.js";
|
||||||
|
import { compileExecutionPlan, packageBuildContexts } from "../execution-world/index.js";
|
||||||
import { bindingSchema, specializeBindingSchema } from "../bindings/index.js";
|
import { bindingSchema, specializeBindingSchema } from "../bindings/index.js";
|
||||||
import {
|
import {
|
||||||
planEvolution,
|
planEvolution,
|
||||||
@@ -13,9 +17,10 @@ import {
|
|||||||
} from "../capability-model/index.js";
|
} from "../capability-model/index.js";
|
||||||
|
|
||||||
const usage = `usage: quixos-workspace-compile --root DIRECTORY --checkout-root DIRECTORY
|
const usage = `usage: quixos-workspace-compile --root DIRECTORY --checkout-root DIRECTORY
|
||||||
[--snapshot-map PATH] [--graph-out PATH] [--workspace-id ID] [--workspace-revision-id ID]
|
[--snapshot-map PATH] [--graph-out PATH] [--plan-out PATH] [--workspace-id ID] [--workspace-revision-id ID]
|
||||||
[--source-root-commit GIT_REV] [--baseline PLAN_JSON] [--evolution-out PATH]
|
[--source-root-commit GIT_REV] [--baseline PLAN_JSON] [--evolution-out PATH]
|
||||||
[--reviews REVIEW_JSON] [--schemas-out PATH]
|
[--reviews REVIEW_JSON] [--schemas-out PATH] [--execution-plan-out PATH] [--build-contexts-out PATH]
|
||||||
|
[--query-generator BUILD_MODULE_PATH] [--checked-resources NIX_MAP]
|
||||||
|
|
||||||
Resolves a workspace's recursive resource-lock graph, clones every exact
|
Resolves a workspace's recursive resource-lock graph, clones every exact
|
||||||
resource revision, validates standalone interface/package manifests, and emits
|
resource revision, validates standalone interface/package manifests, and emits
|
||||||
@@ -36,7 +41,10 @@ const parseArgs = (args: string[]) => {
|
|||||||
rootDirectory,
|
rootDirectory,
|
||||||
checkoutRoot,
|
checkoutRoot,
|
||||||
graphOut: values.get("--graph-out"),
|
graphOut: values.get("--graph-out"),
|
||||||
|
planOut: values.get("--plan-out"),
|
||||||
schemasOut: values.get("--schemas-out"),
|
schemasOut: values.get("--schemas-out"),
|
||||||
|
executionPlanOut: values.get("--execution-plan-out"),
|
||||||
|
buildContextsOut: values.get("--build-contexts-out"),
|
||||||
snapshotMap: values.get("--snapshot-map"),
|
snapshotMap: values.get("--snapshot-map"),
|
||||||
workspaceId: values.get("--workspace-id"),
|
workspaceId: values.get("--workspace-id"),
|
||||||
workspaceRevisionId: values.get("--workspace-revision-id"),
|
workspaceRevisionId: values.get("--workspace-revision-id"),
|
||||||
@@ -44,6 +52,8 @@ const parseArgs = (args: string[]) => {
|
|||||||
baseline: values.get("--baseline"),
|
baseline: values.get("--baseline"),
|
||||||
evolutionOut: values.get("--evolution-out"),
|
evolutionOut: values.get("--evolution-out"),
|
||||||
reviews: values.get("--reviews"),
|
reviews: values.get("--reviews"),
|
||||||
|
queryGenerator: values.get("--query-generator"),
|
||||||
|
checkedResources: values.get("--checked-resources"),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,13 +67,17 @@ const main = async () => {
|
|||||||
checkoutRoot: options.checkoutRoot,
|
checkoutRoot: options.checkoutRoot,
|
||||||
snapshotMap: options.snapshotMap,
|
snapshotMap: options.snapshotMap,
|
||||||
});
|
});
|
||||||
|
const assemblyStarted = performance.now();
|
||||||
const assembled = await compileWorkspaceRepository({
|
const assembled = await compileWorkspaceRepository({
|
||||||
rootDirectory: options.rootDirectory,
|
rootDirectory: options.rootDirectory,
|
||||||
workspaceId: options.workspaceId,
|
workspaceId: options.workspaceId,
|
||||||
workspaceRevisionId: options.workspaceRevisionId,
|
workspaceRevisionId: options.workspaceRevisionId,
|
||||||
sourceRootCommit: options.sourceRootCommit,
|
sourceRootCommit: options.sourceRootCommit,
|
||||||
resolveResource,
|
resolveResource,
|
||||||
|
generateQueries: await loadQueryGenerator(options.queryGenerator),
|
||||||
|
checkedResource: await loadCheckedResources(options.checkedResources),
|
||||||
});
|
});
|
||||||
|
profile("workspace-assembly", assemblyStarted, { resources: assembled.resources.length });
|
||||||
|
|
||||||
if (options.graphOut) {
|
if (options.graphOut) {
|
||||||
await writeFile(
|
await writeFile(
|
||||||
@@ -71,6 +85,7 @@ const main = async () => {
|
|||||||
`${JSON.stringify(
|
`${JSON.stringify(
|
||||||
{
|
{
|
||||||
formatVersion: 1,
|
formatVersion: 1,
|
||||||
|
workspaceId: assembled.workspace.workspaceId,
|
||||||
quixos: assembled.lock.quixos,
|
quixos: assembled.lock.quixos,
|
||||||
directResources: [...assembled.directResources.entries()].map(([bindingKey, node]) => {
|
directResources: [...assembled.directResources.entries()].map(([bindingKey, node]) => {
|
||||||
const [kind, binding] = bindingKey.split("\0");
|
const [kind, binding] = bindingKey.split("\0");
|
||||||
@@ -81,11 +96,15 @@ const main = async () => {
|
|||||||
kind: node.kind,
|
kind: node.kind,
|
||||||
source: node.source,
|
source: node.source,
|
||||||
directory: node.directory,
|
directory: node.directory,
|
||||||
|
...(node.resource.kind === "build"
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
resourceId:
|
resourceId:
|
||||||
node.resource.kind === "interface"
|
node.resource.kind === "interface"
|
||||||
? node.resource.revision.interfaceId
|
? node.resource.revision.interfaceId
|
||||||
: node.resource.revision.packageId,
|
: node.resource.revision.packageId,
|
||||||
revisionId: node.resource.revision.revisionId,
|
revisionId: node.resource.revision.revisionId,
|
||||||
|
}),
|
||||||
dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({
|
dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({
|
||||||
binding,
|
binding,
|
||||||
resourceKey: dependency.key,
|
resourceKey: dependency.key,
|
||||||
@@ -98,16 +117,28 @@ const main = async () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const candidate = { ...assembled.workspace, executionContracts: runtimeContracts(assembled.workspace) };
|
const candidate = { ...assembled.workspace, executionContracts: runtimeContracts(assembled.workspace) };
|
||||||
|
if (options.executionPlanOut || options.buildContextsOut) {
|
||||||
|
const executionStarted = performance.now();
|
||||||
|
const plan = compileExecutionPlan(assembled.workspace);
|
||||||
|
// Machine-consumed immutable metadata should not spend its bounded input
|
||||||
|
// reservation on indentation. Its parsed allocation is charged separately.
|
||||||
|
if (options.executionPlanOut) await writeFile(options.executionPlanOut, `${JSON.stringify(plan)}\n`);
|
||||||
|
if (options.buildContextsOut)
|
||||||
|
await writeFile(options.buildContextsOut, JSON.stringify(packageBuildContexts(plan.definition.bindingSchema)));
|
||||||
|
profile("execution-plan-and-contexts", executionStarted);
|
||||||
|
}
|
||||||
if (options.schemasOut) {
|
if (options.schemasOut) {
|
||||||
|
const schemasStarted = performance.now();
|
||||||
const schemas: Record<string, unknown> = {};
|
const schemas: Record<string, unknown> = {};
|
||||||
for (const node of assembled.resources.filter((entry) => entry.kind === "package")) {
|
for (const node of assembled.capabilityResources.filter((entry) => entry.kind === "package")) {
|
||||||
const closure = new Map<string, typeof node>();
|
const closure = new Map<string, typeof node>();
|
||||||
const visit = (entry: typeof node) => {
|
const visit = (entry: typeof node) => {
|
||||||
if (closure.has(entry.key)) return;
|
if (closure.has(entry.key) || entry.kind === "build") return;
|
||||||
closure.set(entry.key, entry);
|
closure.set(entry.key, entry);
|
||||||
entry.dependencies.forEach(visit);
|
entry.dependencies.forEach(visit);
|
||||||
};
|
};
|
||||||
visit(node);
|
visit(node);
|
||||||
|
if (node.resource.kind !== "package") continue;
|
||||||
schemas[node.resource.revision.revisionId] = specializeBindingSchema(
|
schemas[node.resource.revision.revisionId] = specializeBindingSchema(
|
||||||
bindingSchema({ resources: [...closure.values()] }),
|
bindingSchema({ resources: [...closure.values()] }),
|
||||||
candidate,
|
candidate,
|
||||||
@@ -115,6 +146,7 @@ const main = async () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await writeFile(options.schemasOut, JSON.stringify(schemas));
|
await writeFile(options.schemasOut, JSON.stringify(schemas));
|
||||||
|
profile("package-schemas", schemasStarted);
|
||||||
}
|
}
|
||||||
if (options.evolutionOut) {
|
if (options.evolutionOut) {
|
||||||
const baseline = options.baseline
|
const baseline = options.baseline
|
||||||
@@ -127,7 +159,8 @@ const main = async () => {
|
|||||||
`${JSON.stringify(planEvolution(baseline, candidate, { reviews }), null, 2)}\n`,
|
`${JSON.stringify(planEvolution(baseline, candidate, { reviews }), null, 2)}\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
process.stdout.write(`${JSON.stringify(candidate, null, 2)}\n`);
|
if (options.planOut) await writeFile(options.planOut, `${JSON.stringify(candidate)}\n`);
|
||||||
|
else process.stdout.write(`${JSON.stringify(candidate, null, 2)}\n`);
|
||||||
};
|
};
|
||||||
|
|
||||||
main().catch((error: unknown) => {
|
main().catch((error: unknown) => {
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
capabilityId,
|
||||||
|
type AtomId,
|
||||||
|
type Conformance,
|
||||||
|
type InterfaceRevision,
|
||||||
|
type InterfaceRevisionId,
|
||||||
|
} from "./types.js";
|
||||||
|
import {
|
||||||
|
GenericTypeError,
|
||||||
|
TypeSubstitution,
|
||||||
|
bindTypeParameters,
|
||||||
|
canonicalTypeArgument,
|
||||||
|
type ClosedTypeArgument,
|
||||||
|
type GenericTypeEnvironment,
|
||||||
|
type InterfaceApplicationExpression,
|
||||||
|
type TypeParameter,
|
||||||
|
} from "./generics.js";
|
||||||
|
|
||||||
|
/** Compiler input only. Runtime plans contain ordinary closed conformances. */
|
||||||
|
export interface ConformanceRule {
|
||||||
|
id: string;
|
||||||
|
parameters: TypeParameter[];
|
||||||
|
source: InterfaceApplicationExpression;
|
||||||
|
target: InterfaceApplicationExpression;
|
||||||
|
}
|
||||||
|
export interface RuleApplication {
|
||||||
|
rule: ConformanceRule;
|
||||||
|
atomId: AtomId;
|
||||||
|
sourceId: InterfaceRevisionId;
|
||||||
|
targetId: InterfaceRevisionId;
|
||||||
|
bindings: Map<string, ClosedTypeArgument>;
|
||||||
|
id: Conformance["id"];
|
||||||
|
}
|
||||||
|
const parameterId = (argument: ConformanceRule["source"]["arguments"][number]) => {
|
||||||
|
const value = argument.kind === "value" ? argument.type : argument.target;
|
||||||
|
return value.kind === "parameter" ? value.parameterId : undefined;
|
||||||
|
};
|
||||||
|
const hasParameter = (node: unknown): boolean =>
|
||||||
|
!!node &&
|
||||||
|
typeof node === "object" &&
|
||||||
|
(("kind" in node && node.kind === "parameter") || Object.values(node).some(hasParameter));
|
||||||
|
|
||||||
|
export function validateConformanceRules(
|
||||||
|
rules: readonly ConformanceRule[],
|
||||||
|
definitions: ReadonlyMap<string, InterfaceRevision>,
|
||||||
|
) {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const graph = new Map<string, ConformanceRule[]>();
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (ids.has(rule.id)) throw new GenericTypeError("duplicate-rule-id", rule.id, `Duplicate rule ID ${rule.id}`);
|
||||||
|
ids.add(rule.id);
|
||||||
|
for (const application of [
|
||||||
|
rule.source,
|
||||||
|
rule.target,
|
||||||
|
...rule.parameters.flatMap((p) => (p.kind === "object" ? p.implements : [])),
|
||||||
|
]) {
|
||||||
|
const definition = definitions.get(application.definitionId);
|
||||||
|
if (!definition)
|
||||||
|
throw new GenericTypeError("unknown-interface", rule.id, `Unknown interface ${application.definitionId}`);
|
||||||
|
const parameters = definition.template?.parameters ?? [];
|
||||||
|
if (parameters.length !== application.arguments.length)
|
||||||
|
throw new GenericTypeError("type-arity", rule.id, `Wrong arity for ${definition.displayName}`);
|
||||||
|
parameters.forEach((p, i) => {
|
||||||
|
if (p.kind !== application.arguments[i].kind)
|
||||||
|
throw new GenericTypeError(
|
||||||
|
"parameter-kind",
|
||||||
|
rule.id,
|
||||||
|
`Wrong argument kind for ${definition.displayName}.${p.name}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const captured = new Set<string>();
|
||||||
|
for (const argument of rule.source.arguments) {
|
||||||
|
const id = parameterId(argument);
|
||||||
|
if (id) captured.add(id);
|
||||||
|
else if (hasParameter(argument))
|
||||||
|
throw new GenericTypeError(
|
||||||
|
"unsupported-rule-pattern",
|
||||||
|
rule.id,
|
||||||
|
"Capture whole source arguments, not nested type patterns",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const p of rule.parameters)
|
||||||
|
if (!captured.has(p.id))
|
||||||
|
throw new GenericTypeError(
|
||||||
|
"uncaptured-rule-parameter",
|
||||||
|
rule.id,
|
||||||
|
`Parameter ${p.name} must occur in the source application`,
|
||||||
|
);
|
||||||
|
graph.set(rule.source.definitionId, [...(graph.get(rule.source.definitionId) ?? []), rule]);
|
||||||
|
}
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const active = new Set<string>();
|
||||||
|
const visit = (id: string, trail: string[]) => {
|
||||||
|
if (active.has(id))
|
||||||
|
throw new GenericTypeError(
|
||||||
|
"recursive-conformance-rule",
|
||||||
|
trail.at(-1) ?? id,
|
||||||
|
`Recursive conformance rules: ${trail.join(" -> ")}`,
|
||||||
|
);
|
||||||
|
if (visited.has(id)) return;
|
||||||
|
active.add(id);
|
||||||
|
for (const rule of graph.get(id) ?? []) visit(rule.target.definitionId, [...trail, rule.id]);
|
||||||
|
active.delete(id);
|
||||||
|
visited.add(id);
|
||||||
|
};
|
||||||
|
for (const id of graph.keys()) visit(id, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monotone closure: bounds only enable applications, never invent evidence. */
|
||||||
|
export function expandConformanceRules(
|
||||||
|
rules: readonly ConformanceRule[],
|
||||||
|
initial: readonly Pick<Conformance, "atomId" | "interfaceRevisionId">[],
|
||||||
|
definitions: ReadonlyMap<string, InterfaceRevision>,
|
||||||
|
environment: (atom: AtomId, bindings: Map<string, ClosedTypeArgument>) => GenericTypeEnvironment,
|
||||||
|
): RuleApplication[] {
|
||||||
|
validateConformanceRules(rules, definitions);
|
||||||
|
const key = (atom: string, iface: string) => JSON.stringify([atom, iface]);
|
||||||
|
const facts = new Map(initial.map((f) => [key(f.atomId, f.interfaceRevisionId), f]));
|
||||||
|
const applications = new Map<string, RuleApplication>();
|
||||||
|
const implies = (actual: InterfaceRevisionId, required: InterfaceRevisionId, seen = new Set<string>()): boolean => {
|
||||||
|
if (actual === required) return true;
|
||||||
|
if (seen.has(actual)) return false;
|
||||||
|
seen.add(actual);
|
||||||
|
return (definitions.get(actual)?.requiredInterfaces ?? []).some((id) => implies(id, required, seen));
|
||||||
|
};
|
||||||
|
const bySource = new Map<string, ConformanceRule[]>();
|
||||||
|
for (const rule of [...rules].sort((a, b) => a.id.localeCompare(b.id)))
|
||||||
|
bySource.set(rule.source.definitionId, [...(bySource.get(rule.source.definitionId) ?? []), rule]);
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const fact of [...facts.values()].sort((a, b) =>
|
||||||
|
key(a.atomId, a.interfaceRevisionId).localeCompare(key(b.atomId, b.interfaceRevisionId)),
|
||||||
|
)) {
|
||||||
|
const definition = definitions.get(fact.interfaceRevisionId);
|
||||||
|
if (!definition) continue;
|
||||||
|
for (const rule of bySource.get(definition.application?.definitionId ?? definition.revisionId) ?? []) {
|
||||||
|
const arguments_ = definition.application?.arguments ?? [];
|
||||||
|
const bindings = new Map<string, ClosedTypeArgument>();
|
||||||
|
const env = environment(fact.atomId, bindings);
|
||||||
|
const substitution = new TypeSubstitution(env);
|
||||||
|
if (
|
||||||
|
!rule.source.arguments.every((pattern, i) => {
|
||||||
|
const actual = arguments_[i];
|
||||||
|
if (!actual || actual.kind !== pattern.kind) return false;
|
||||||
|
const id = parameterId(pattern);
|
||||||
|
if (!id) return canonicalTypeArgument(substitution.argument(pattern)) === canonicalTypeArgument(actual);
|
||||||
|
const previous = bindings.get(id);
|
||||||
|
if (previous && canonicalTypeArgument(previous) !== canonicalTypeArgument(actual)) return false;
|
||||||
|
bindings.set(id, actual);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
try {
|
||||||
|
bindTypeParameters(
|
||||||
|
rule.parameters,
|
||||||
|
rule.parameters.map((p) => bindings.get(p.id)!),
|
||||||
|
{
|
||||||
|
...env,
|
||||||
|
implementsInterface: (target, required) =>
|
||||||
|
target.kind === "atom"
|
||||||
|
? facts.has(key(target.atomId, required))
|
||||||
|
: implies(target.interfaceRevisionId, required),
|
||||||
|
},
|
||||||
|
rule.id,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GenericTypeError && ["unsatisfied-bound", "non-storable-argument"].includes(error.code))
|
||||||
|
continue;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const applicationKey = JSON.stringify([rule.id, fact.atomId, fact.interfaceRevisionId]);
|
||||||
|
if (applications.has(applicationKey)) continue;
|
||||||
|
if (applications.size >= 10000)
|
||||||
|
throw new GenericTypeError(
|
||||||
|
"rule-expansion-limit",
|
||||||
|
rule.id,
|
||||||
|
"Conformance rule expansion exceeds 10000 applications",
|
||||||
|
);
|
||||||
|
const targetId = substitution.application(rule.target);
|
||||||
|
const id = capabilityId.conformance(
|
||||||
|
`conformance-rule:sha256:${createHash("sha256")
|
||||||
|
.update(
|
||||||
|
JSON.stringify(["quixos-conformance-rule-v1", rule.id, fact.atomId, fact.interfaceRevisionId, targetId]),
|
||||||
|
)
|
||||||
|
.digest("hex")}`,
|
||||||
|
);
|
||||||
|
applications.set(applicationKey, {
|
||||||
|
rule,
|
||||||
|
atomId: fact.atomId,
|
||||||
|
sourceId: fact.interfaceRevisionId,
|
||||||
|
targetId,
|
||||||
|
bindings,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
const targetKey = key(fact.atomId, targetId);
|
||||||
|
if (!facts.has(targetKey)) {
|
||||||
|
facts.set(targetKey, { atomId: fact.atomId, interfaceRevisionId: targetId });
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...applications.values()];
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { InterfaceRevision, PackageController, WorkspaceRevision } from "./types.js";
|
||||||
|
import type { CapabilityValidationIssue } from "./validation.js";
|
||||||
|
import { canonicalJson } from "./evolution.js";
|
||||||
|
|
||||||
|
export function validateControllers(
|
||||||
|
controllers: readonly PackageController[],
|
||||||
|
interfaces: readonly InterfaceRevision[],
|
||||||
|
prefix: string,
|
||||||
|
queries: readonly import("../query/types.js").CheckedQuery[] = [],
|
||||||
|
): CapabilityValidationIssue[] {
|
||||||
|
const issues: CapabilityValidationIssue[] = [],
|
||||||
|
ids = new Set<string>();
|
||||||
|
const fail = (path: string, message: string) => issues.push({ code: "invalid-controller" as const, path, message });
|
||||||
|
for (const [index, controller] of controllers.entries()) {
|
||||||
|
const path = `${prefix}[${index}]`;
|
||||||
|
if (!controller.id.trim() || ids.has(controller.id))
|
||||||
|
fail(`${path}.id`, "Controller identities must be nonempty and unique within the package");
|
||||||
|
ids.add(controller.id);
|
||||||
|
if (controller.registry !== undefined) {
|
||||||
|
const query = queries.find((query) => query.declaration.id === controller.registry);
|
||||||
|
const selected = (entries: import("../query/types.js").QuerySelection[] | undefined, name: string) =>
|
||||||
|
entries?.find((entry) => entry.name === name && entry.key === name && !entry.conditions.length);
|
||||||
|
const root = selected(query?.selection, "root"),
|
||||||
|
objects = selected(root?.selection, "objects"),
|
||||||
|
entries = selected(objects?.selection, "entries"),
|
||||||
|
node = selected(entries?.selection, "node"),
|
||||||
|
qx = selected(node?.selection, "_camino"),
|
||||||
|
ref = selected(qx?.selection, "ref");
|
||||||
|
const all = objects?.arguments.all;
|
||||||
|
if (
|
||||||
|
!query ||
|
||||||
|
query.declaration.root.kind !== "workspace-interface" ||
|
||||||
|
query.declaration.root.interface !== controller.subject ||
|
||||||
|
query.declaration.placement?.kind === "local" ||
|
||||||
|
query.effects.some((effect) => effect.execution === "rpc-permitted") ||
|
||||||
|
query.variables.kind !== "record" ||
|
||||||
|
Object.keys(query.variables.fields).length ||
|
||||||
|
!ref ||
|
||||||
|
all?.kind !== "literal" ||
|
||||||
|
!/^\d+$/.test(String(all.value)) ||
|
||||||
|
!Number.isSafeInteger(Number(all.value)) ||
|
||||||
|
Number(all.value) < 1 ||
|
||||||
|
Number(all.value) > 64
|
||||||
|
)
|
||||||
|
fail(
|
||||||
|
`${path}.registry`,
|
||||||
|
"Controller registry requires a native/portable workspace query over its subject, no variables, and root.objects(all: 1..64).entries.node._camino.ref without aliases or conditional selections",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const iface = interfaces.find((i) => i.revisionId === controller.subject && !i.template);
|
||||||
|
if (!iface) {
|
||||||
|
fail(`${path}.subject`, "Controller subject must be an imported closed interface");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const operations = iface.members.flatMap((m) => m.operations);
|
||||||
|
const snapshot = operations.find((o) => o.id === controller.snapshot),
|
||||||
|
remote = operations.find((o) => o.id === controller.remote);
|
||||||
|
const unit = (value: unknown) => canonicalJson(value) === canonicalJson({ kind: "builtin", name: "unit" });
|
||||||
|
if (
|
||||||
|
!snapshot ||
|
||||||
|
snapshot.scope === "class" ||
|
||||||
|
snapshot.mode !== "call" ||
|
||||||
|
snapshot.execution?.kind !== "snapshot" ||
|
||||||
|
!snapshot.execution.targets.includes("server") ||
|
||||||
|
!unit(snapshot.inputType)
|
||||||
|
)
|
||||||
|
fail(`${path}.snapshot`, "Controller snapshot must be a server snapshot object call with unit input");
|
||||||
|
if (
|
||||||
|
!remote ||
|
||||||
|
remote.scope === "class" ||
|
||||||
|
remote.mode !== "call" ||
|
||||||
|
(remote.execution?.kind ?? "remote") !== "remote"
|
||||||
|
)
|
||||||
|
fail(`${path}.remote`, "Controller target must be a remote object call");
|
||||||
|
if (controller.input !== "unit" && controller.input !== "value")
|
||||||
|
fail(`${path}.input`, "Controller input must be unit or value");
|
||||||
|
else if (
|
||||||
|
remote &&
|
||||||
|
(controller.input === "unit"
|
||||||
|
? !unit(remote.inputType)
|
||||||
|
: snapshot && canonicalJson(snapshot.outputType) !== canonicalJson(remote.inputType))
|
||||||
|
)
|
||||||
|
fail(`${path}.input`, "Controller input mapping does not match the remote operation input");
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
export function validateWorkspaceControllers(workspace: WorkspaceRevision): CapabilityValidationIssue[] {
|
||||||
|
return workspace.packageImports.flatMap((pkg, index) =>
|
||||||
|
validateControllers(
|
||||||
|
pkg.controllers ?? [],
|
||||||
|
workspace.interfaceImports,
|
||||||
|
`packageImports[${index}].controllers`,
|
||||||
|
pkg.checkedQueries,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** The file is declarative metadata, not an executable registration callback. */
|
||||||
|
export function parseControllerCatalog(
|
||||||
|
text: string,
|
||||||
|
interfaces: readonly InterfaceRevision[],
|
||||||
|
queries: readonly import("../query/types.js").CheckedQuery[] = [],
|
||||||
|
): PackageController[] {
|
||||||
|
if (Buffer.byteLength(text) > 1048576) throw new Error("Controller catalog exceeds 1 MiB");
|
||||||
|
const value: unknown = JSON.parse(text);
|
||||||
|
const record = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
!!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
if (
|
||||||
|
!record(value) ||
|
||||||
|
value.schemaVersion !== 1 ||
|
||||||
|
Object.keys(value).some((k) => !["schemaVersion", "controllers"].includes(k)) ||
|
||||||
|
!Array.isArray(value.controllers) ||
|
||||||
|
value.controllers.length > 1000
|
||||||
|
)
|
||||||
|
throw new Error("Invalid controller catalog: expected schemaVersion 1 and at most 1000 controllers");
|
||||||
|
const fields = ["id", "subject", "snapshot", "remote", "input"];
|
||||||
|
for (const entry of value.controllers)
|
||||||
|
if (
|
||||||
|
!record(entry) ||
|
||||||
|
Object.keys(entry).some((key) => ![...fields, "registry"].includes(key)) ||
|
||||||
|
(entry.registry !== undefined && typeof entry.registry !== "string") ||
|
||||||
|
fields.some((key) => typeof entry[key] !== "string")
|
||||||
|
)
|
||||||
|
throw new Error("Invalid controller entry: expected exactly id, subject, snapshot, remote, input strings");
|
||||||
|
const controllers = value.controllers as PackageController[];
|
||||||
|
const issues = validateControllers(controllers, interfaces, "controllers", queries);
|
||||||
|
if (issues.length) throw new Error(issues.map((i) => `${i.path}: ${i.message}`).join("\n"));
|
||||||
|
return controllers;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
import { setImmediate } from "node:timers/promises";
|
||||||
import type { Binding, Conformance, DependencyBinding, PersistentAttachment, WorkspaceRevision } from "./types.js";
|
import type { Binding, Conformance, DependencyBinding, PersistentAttachment, WorkspaceRevision } from "./types.js";
|
||||||
import { validateWorkspaceRevision } from "./validation.js";
|
import { validateWorkspaceRevision } from "./validation.js";
|
||||||
|
|
||||||
@@ -21,6 +22,89 @@ export const canonicalJson = (value: unknown): string => {
|
|||||||
};
|
};
|
||||||
export const contentDigest = (value: unknown) =>
|
export const contentDigest = (value: unknown) =>
|
||||||
`sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
`sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
||||||
|
/** Same canonical bytes as contentDigest, without monopolizing a live host's
|
||||||
|
* event loop while verifying large immutable metadata. No validation is skipped.
|
||||||
|
* This is for asynchronous loading, not a different build/wire identity. */
|
||||||
|
export async function contentDigestAsync(value: unknown): Promise<string> {
|
||||||
|
// An explicit stack visits each node once; recursive generator delegation
|
||||||
|
// otherwise forwards every token through every ancestor of a deep plan.
|
||||||
|
type Frame = { kind: "value" | "array" | "object"; value: unknown; index?: number; keys?: string[]; wrote?: boolean };
|
||||||
|
const hash = createHash("sha256"),
|
||||||
|
stack: Frame[] = [{ kind: "value", value }],
|
||||||
|
active = new WeakSet<object>();
|
||||||
|
let buffer = "",
|
||||||
|
sliceStarted = performance.now(),
|
||||||
|
steps = 0;
|
||||||
|
const append = (part: string) => {
|
||||||
|
buffer += part;
|
||||||
|
if (buffer.length >= 65536) {
|
||||||
|
hash.update(buffer);
|
||||||
|
buffer = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
while (stack.length) {
|
||||||
|
const frame = stack[stack.length - 1]!;
|
||||||
|
if (frame.kind === "value") {
|
||||||
|
const value = frame.value;
|
||||||
|
if (value !== null && typeof value === "object") {
|
||||||
|
if (active.has(value)) throw new Error("Cannot hash cyclic JSON values");
|
||||||
|
active.add(value);
|
||||||
|
frame.index = 0;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
frame.kind = "array";
|
||||||
|
append("[");
|
||||||
|
} else {
|
||||||
|
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
||||||
|
throw new Error("Expected a plain JSON object");
|
||||||
|
frame.kind = "object";
|
||||||
|
frame.keys = Object.keys(value).sort(compareText);
|
||||||
|
frame.wrote = false;
|
||||||
|
append("{");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
append(canonicalJson(value));
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
} else if (frame.kind === "array") {
|
||||||
|
const array = frame.value as unknown[];
|
||||||
|
if (frame.index === array.length) {
|
||||||
|
append("]");
|
||||||
|
active.delete(array);
|
||||||
|
stack.pop();
|
||||||
|
} else {
|
||||||
|
const index = frame.index!;
|
||||||
|
frame.index = index + 1;
|
||||||
|
if (index) append(",");
|
||||||
|
// Match canonicalJson's Array.map behavior for sparse arrays too.
|
||||||
|
if (index in array) stack.push({ kind: "value", value: array[index] });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const object = frame.value as Record<string, unknown>;
|
||||||
|
if (frame.index === frame.keys!.length) {
|
||||||
|
append("}");
|
||||||
|
active.delete(object);
|
||||||
|
stack.pop();
|
||||||
|
} else {
|
||||||
|
const index = frame.index!;
|
||||||
|
frame.index = index + 1;
|
||||||
|
const key = frame.keys![index]!,
|
||||||
|
entry = object[key];
|
||||||
|
if (entry !== undefined) {
|
||||||
|
if (frame.wrote) append(",");
|
||||||
|
frame.wrote = true;
|
||||||
|
append(JSON.stringify(key) + ":");
|
||||||
|
stack.push({ kind: "value", value: entry });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (++steps % 512 === 0 && performance.now() - sliceStarted >= 8) {
|
||||||
|
await setImmediate();
|
||||||
|
sliceStarted = performance.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hash.update(buffer);
|
||||||
|
return `sha256:${hash.digest("hex")}`;
|
||||||
|
}
|
||||||
const semantic = (value: unknown): unknown => {
|
const semantic = (value: unknown): unknown => {
|
||||||
if (Array.isArray(value)) return value.map(semantic);
|
if (Array.isArray(value)) return value.map(semantic);
|
||||||
if (value && typeof value === "object")
|
if (value && typeof value === "object")
|
||||||
@@ -132,8 +216,27 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
const dependency = (parent: GraphNode, binding: DependencyBinding, atomId: string, reviews?: Set<string>) => {
|
const dependency = (parent: GraphNode, binding: DependencyBinding, atomId: string, reviews?: Set<string>) => {
|
||||||
|
if (binding.kind === "query") {
|
||||||
|
const pkg = workspace.packageImports.find((pkg) => pkg.revisionId === binding.packageRevisionId);
|
||||||
|
const query = pkg?.checkedQueries?.find((query) => query.declaration.id === binding.queryId);
|
||||||
|
if (!query) throw new Error(`Missing checked query contract ${binding.packageRevisionId}:${binding.queryId}`);
|
||||||
|
const key = `query:${binding.packageRevisionId}:${binding.queryId}`;
|
||||||
|
const queryNode = nodes.get(key) ?? node(key, query);
|
||||||
|
parent.dependencies.add(key);
|
||||||
|
for (const effect of query.effects) {
|
||||||
|
queryNode.dependencies.add(`interface:${effect.interfaceRevisionId}`);
|
||||||
|
for (const conformance of workspace.conformances.filter(
|
||||||
|
(entry) => entry.interfaceRevisionId === effect.interfaceRevisionId,
|
||||||
|
))
|
||||||
|
queryNode.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (binding.kind === "state") parent.dependencies.add(`attachment:${binding.slotId}`);
|
if (binding.kind === "state") parent.dependencies.add(`attachment:${binding.slotId}`);
|
||||||
if (binding.kind === "edge") parent.dependencies.add(`attachment:${binding.edgeTypeId}`);
|
if (binding.kind === "edge") parent.dependencies.add(`attachment:${binding.edgeTypeId}`);
|
||||||
|
if (binding.kind === "creation") {
|
||||||
|
for (const owner of binding.owners)
|
||||||
|
parent.dependencies.add(conformanceKey(binding.atomId, owner.interfaceRevisionId));
|
||||||
|
}
|
||||||
if (binding.kind === "constructor") {
|
if (binding.kind === "constructor") {
|
||||||
parent.dependencies.add(`constructor:${binding.atomId}`);
|
parent.dependencies.add(`constructor:${binding.atomId}`);
|
||||||
const ctor = workspace.constructors.find((entry) => entry.atomId === binding.atomId);
|
const ctor = workspace.constructors.find((entry) => entry.atomId === binding.atomId);
|
||||||
@@ -152,8 +255,22 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[
|
|||||||
parent.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId));
|
parent.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId));
|
||||||
reviews?.add(conformanceIdentity(conformance));
|
reviews?.add(conformanceIdentity(conformance));
|
||||||
for (const operation of conformance.operationBindings) {
|
for (const operation of conformance.operationBindings) {
|
||||||
if (operation.binding.kind !== "package") continue;
|
let provider: Binding | undefined = operation.binding;
|
||||||
const revisionId = operation.binding.packageRevisionId;
|
const visited = new Set<string>();
|
||||||
|
while (provider?.kind === "interface") {
|
||||||
|
const forward: Extract<Binding, { kind: "interface" }> = provider;
|
||||||
|
const key = `${provider.interfaceRevisionId}\0${provider.operationId}`;
|
||||||
|
if (visited.has(key)) throw new Error("Forwarding cycle in evolution contract");
|
||||||
|
visited.add(key);
|
||||||
|
const target = workspace.conformances.find(
|
||||||
|
(c) => c.atomId === conformance.atomId && c.interfaceRevisionId === forward.interfaceRevisionId,
|
||||||
|
);
|
||||||
|
if (!target) throw new Error("Missing forwarding conformance in evolution contract");
|
||||||
|
reviews?.add(conformanceIdentity(target));
|
||||||
|
provider = target.operationBindings.find((entry) => entry.operationId === forward.operationId)?.binding;
|
||||||
|
}
|
||||||
|
if (provider?.kind !== "package") continue;
|
||||||
|
const revisionId = provider.packageRevisionId;
|
||||||
const pkg = workspace.packageImports.find((entry) => entry.revisionId === revisionId);
|
const pkg = workspace.packageImports.find((entry) => entry.revisionId === revisionId);
|
||||||
if (pkg) reviews?.add(pkg.packageId);
|
if (pkg) reviews?.add(pkg.packageId);
|
||||||
}
|
}
|
||||||
@@ -162,6 +279,11 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const binding = (parent: GraphNode, value: Binding, atomId: string, context: unknown) => {
|
const binding = (parent: GraphNode, value: Binding, atomId: string, context: unknown) => {
|
||||||
|
if (value.kind === "lifecycle") return;
|
||||||
|
if (value.kind === "journal" || value.kind === "subscription") {
|
||||||
|
dependency(parent, { kind: "state", slotId: value.slotId }, atomId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (value.kind !== "package") {
|
if (value.kind !== "package") {
|
||||||
dependency(parent, value, atomId);
|
dependency(parent, value, atomId);
|
||||||
return;
|
return;
|
||||||
@@ -181,9 +303,17 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[
|
|||||||
atomId: conformance.atomId,
|
atomId: conformance.atomId,
|
||||||
interfaceRevisionId: conformance.interfaceRevisionId,
|
interfaceRevisionId: conformance.interfaceRevisionId,
|
||||||
operations: sorted(conformance.operationBindings, (entry) => entry.operationId),
|
operations: sorted(conformance.operationBindings, (entry) => entry.operationId),
|
||||||
|
...(conformance.moduleBindings?.length
|
||||||
|
? { modules: sorted(conformance.moduleBindings, (entry) => entry.memberId) }
|
||||||
|
: {}),
|
||||||
|
...(conformance.initialization
|
||||||
|
? { initialization: sorted(conformance.initialization, (entry) => entry.input) }
|
||||||
|
: {}),
|
||||||
materializations: sorted(conformance.relationshipMaterializations, (entry) => entry.memberId),
|
materializations: sorted(conformance.relationshipMaterializations, (entry) => entry.memberId),
|
||||||
});
|
});
|
||||||
parent.dependencies.add(`interface:${conformance.interfaceRevisionId}`);
|
parent.dependencies.add(`interface:${conformance.interfaceRevisionId}`);
|
||||||
|
for (const module of conformance.moduleBindings ?? [])
|
||||||
|
parent.dependencies.add(`package:${module.packageRevisionId}`);
|
||||||
const contract = workspace.interfaceImports.find((entry) => entry.revisionId === conformance.interfaceRevisionId);
|
const contract = workspace.interfaceImports.find((entry) => entry.revisionId === conformance.interfaceRevisionId);
|
||||||
for (const required of contract?.requiredInterfaces ?? [])
|
for (const required of contract?.requiredInterfaces ?? [])
|
||||||
parent.dependencies.add(conformanceKey(conformance.atomId, required));
|
parent.dependencies.add(conformanceKey(conformance.atomId, required));
|
||||||
@@ -330,6 +460,36 @@ export const planEvolution = (
|
|||||||
...(next ? { candidate: next } : {}),
|
...(next ? { candidate: next } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const semanticReview = assessSemanticReviews(baseline, candidate, options.reviews ?? [], checkerVersion, [
|
||||||
|
...nextRuntimes.values(),
|
||||||
|
]);
|
||||||
|
const reviews = semanticReview.reviews;
|
||||||
|
blockers.push(...semanticReview.blockers);
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
baselineDigest: baseline ? contentDigest(baseline) : null,
|
||||||
|
candidateDigest,
|
||||||
|
checkerVersion,
|
||||||
|
runtimeActions,
|
||||||
|
storageChanges,
|
||||||
|
migrationRequired: storageChanges.filter((entry) => entry.requiresMigration).map((entry) => entry.id),
|
||||||
|
reviews,
|
||||||
|
packageChecks: runtimeActions
|
||||||
|
.filter((entry) => entry.candidate && entry.action !== "keep")
|
||||||
|
.map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })),
|
||||||
|
blockers,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Review dependency contracts, without making any storage or process-placement decision. */
|
||||||
|
function assessSemanticReviews(
|
||||||
|
baseline: WorkspaceRevision | null,
|
||||||
|
candidate: WorkspaceRevision,
|
||||||
|
receipts: readonly EvolutionReview[],
|
||||||
|
checkerVersion: string,
|
||||||
|
consumers: readonly RuntimeContract[],
|
||||||
|
): Pick<EvolutionReport, "reviews" | "blockers"> {
|
||||||
|
const blockers: string[] = [];
|
||||||
const providers = (workspace: WorkspaceRevision) => [
|
const providers = (workspace: WorkspaceRevision) => [
|
||||||
...workspace.packageImports.map((entry) => ({
|
...workspace.packageImports.map((entry) => ({
|
||||||
id: entry.packageId as string,
|
id: entry.packageId as string,
|
||||||
@@ -356,7 +516,7 @@ export const planEvolution = (
|
|||||||
}
|
}
|
||||||
if (!old[0] || old[0].major === provider.major) continue;
|
if (!old[0] || old[0].major === provider.major) continue;
|
||||||
if (provider.major < old[0].major) blockers.push(`Semantic major decreases for ${provider.id}`);
|
if (provider.major < old[0].major) blockers.push(`Semantic major decreases for ${provider.id}`);
|
||||||
for (const consumer of nextRuntimes.values()) {
|
for (const consumer of consumers) {
|
||||||
if (consumer.packageId === provider.id || !consumer.reviewProviders.includes(provider.id)) continue;
|
if (consumer.packageId === provider.id || !consumer.reviewProviders.includes(provider.id)) continue;
|
||||||
const requirement = {
|
const requirement = {
|
||||||
consumerId: consumer.groupId,
|
consumerId: consumer.groupId,
|
||||||
@@ -371,7 +531,7 @@ export const planEvolution = (
|
|||||||
consumer: consumer.digest,
|
consumer: consumer.digest,
|
||||||
checkerVersion,
|
checkerVersion,
|
||||||
});
|
});
|
||||||
const accepted = (options.reviews ?? []).some(
|
const accepted = receipts.some(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
entry.requirementDigest === requirementDigest &&
|
entry.requirementDigest === requirementDigest &&
|
||||||
["changed", "accepted-unchanged"].includes(entry.decision) &&
|
["changed", "accepted-unchanged"].includes(entry.decision) &&
|
||||||
@@ -382,18 +542,27 @@ export const planEvolution = (
|
|||||||
if (!accepted) blockers.push(`Semantic-major review required: ${consumer.groupId} consumes ${provider.id}`);
|
if (!accepted) blockers.push(`Semantic-major review required: ${consumer.groupId} consumes ${provider.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { reviews, blockers };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reviewSemanticMajors(
|
||||||
|
baseline: WorkspaceRevision | null,
|
||||||
|
candidate: WorkspaceRevision,
|
||||||
|
receipts: readonly EvolutionReview[] = [],
|
||||||
|
) {
|
||||||
|
for (const workspace of baseline ? [baseline, candidate] : [candidate]) {
|
||||||
|
const issues = validateWorkspaceRevision(workspace);
|
||||||
|
if (issues.length)
|
||||||
|
throw new Error("Invalid semantic-review workspace: " + issues.map((issue) => issue.message).join("; "));
|
||||||
|
}
|
||||||
|
if (baseline && baseline.workspaceId !== candidate.workspaceId)
|
||||||
|
throw new Error("Cannot review a different workspace");
|
||||||
|
const checkerVersion = "quixos-semantic-review-v1";
|
||||||
|
const report = assessSemanticReviews(baseline, candidate, receipts, checkerVersion, runtimeContracts(candidate));
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
|
||||||
baselineDigest: baseline ? contentDigest(baseline) : null,
|
|
||||||
candidateDigest,
|
|
||||||
checkerVersion,
|
checkerVersion,
|
||||||
runtimeActions,
|
baselineDigest: baseline ? contentDigest(baseline) : null,
|
||||||
storageChanges,
|
candidateDigest: contentDigest(candidate),
|
||||||
migrationRequired: storageChanges.filter((entry) => entry.requiresMigration).map((entry) => entry.id),
|
...report,
|
||||||
reviews,
|
|
||||||
packageChecks: runtimeActions
|
|
||||||
.filter((entry) => entry.candidate && entry.action !== "keep")
|
|
||||||
.map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })),
|
|
||||||
blockers,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import type { ExecutionContract, InterfaceOperation, WorkspaceRevision } from "./types.js";
|
||||||
|
import type { CapabilityValidationIssue } from "./validation.js";
|
||||||
|
import { resolveForwardedBinding } from "./forwarding.js";
|
||||||
|
|
||||||
|
/** Validate declared effects and targets; this is not a JavaScript purity proof. */
|
||||||
|
export function validateExecutionContracts(workspace: WorkspaceRevision): CapabilityValidationIssue[] {
|
||||||
|
const issues: CapabilityValidationIssue[] = [];
|
||||||
|
const fail = (path: string, message: string) => issues.push({ code: "invalid-execution-contract", path, message });
|
||||||
|
const interfaces = new Map(workspace.interfaceImports.map((i) => [i.revisionId, i]));
|
||||||
|
const packages = new Map(workspace.packageImports.map((p) => [p.revisionId, p]));
|
||||||
|
const operations = (id: InterfaceOperation["id"], revision: WorkspaceRevision["interfaceImports"][number]) =>
|
||||||
|
(revision.template?.members ?? revision.members).flatMap((m) => m.operations).find((o) => o.id === id);
|
||||||
|
const check = (contract: ExecutionContract | undefined, path: string) => {
|
||||||
|
if (!contract || contract.kind === "remote") return;
|
||||||
|
if (
|
||||||
|
!["snapshot", "transaction"].includes(contract.kind) ||
|
||||||
|
!Array.isArray(contract.targets) ||
|
||||||
|
!contract.targets.length ||
|
||||||
|
contract.targets.some((t) => typeof t !== "string" || !t.trim()) ||
|
||||||
|
new Set(contract.targets).size !== contract.targets.length
|
||||||
|
)
|
||||||
|
fail(path, "Portable execution requires a nonempty set of distinct target names");
|
||||||
|
};
|
||||||
|
const supports = (
|
||||||
|
requested: ExecutionContract | undefined,
|
||||||
|
provided: ExecutionContract | undefined,
|
||||||
|
path: string,
|
||||||
|
) => {
|
||||||
|
if (!requested || requested.kind === "remote") return;
|
||||||
|
if (!provided || provided.kind === "remote") {
|
||||||
|
fail(path, "Portable execution depends on a remote-only operation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requested.kind === "snapshot" && provided.kind !== "snapshot")
|
||||||
|
fail(path, "Snapshot execution cannot perform a transaction");
|
||||||
|
};
|
||||||
|
for (const revision of workspace.interfaceImports)
|
||||||
|
for (const member of revision.template?.members ?? revision.members) {
|
||||||
|
for (const operation of member.operations) {
|
||||||
|
check(operation.execution, `${revision.revisionId}/${operation.id}`);
|
||||||
|
if (operation.execution && operation.execution.kind !== "remote" && operation.mode !== "call")
|
||||||
|
fail(
|
||||||
|
`${revision.revisionId}/${operation.id}`,
|
||||||
|
"Watch lifecycle operations are remote; portable reads use snapshots",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (member.kind !== "operation" && member.kind !== "module" && member.queryRead?.execution === "portable") {
|
||||||
|
const getter = member.operations.find((o) => o.displayName === (member.kind === "value" ? "get" : "resolve"));
|
||||||
|
if (getter?.execution?.kind !== "snapshot")
|
||||||
|
fail(`${revision.revisionId}/${member.id}`, "Queryable portable members require a snapshot getter");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const revision of workspace.packageImports) {
|
||||||
|
for (const entry of [...revision.exports, ...(revision.genericExports ?? [])]) {
|
||||||
|
const path = `${revision.revisionId}/${entry.id}`;
|
||||||
|
check(entry.execution, path);
|
||||||
|
for (const port of entry.dependencyPorts) {
|
||||||
|
const requirement = port.requirement;
|
||||||
|
const portPath = `${path}/${port.id}`;
|
||||||
|
if (requirement.kind === "interface") {
|
||||||
|
const contract = interfaces.get(
|
||||||
|
"interfaceRevisionId" in requirement
|
||||||
|
? requirement.interfaceRevisionId
|
||||||
|
: requirement.application.definitionId,
|
||||||
|
);
|
||||||
|
const selected = requirement.operations;
|
||||||
|
if (requirement.receiver === "input") {
|
||||||
|
if (!entry.execution || entry.execution.kind === "remote" || !selected?.length)
|
||||||
|
fail(portPath, "Reference-selected calls require portable execution and an explicit selection");
|
||||||
|
for (const id of selected ?? []) {
|
||||||
|
const operation = contract && operations(id, contract);
|
||||||
|
if (!operation?.execution || operation.execution.kind === "remote" || operation.scope === "class")
|
||||||
|
fail(portPath, "Reference-selected dependencies require portable object operations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selected && (!selected.length || new Set(selected).size !== selected.length))
|
||||||
|
fail(portPath, "Selected interface operations must be nonempty and distinct");
|
||||||
|
for (const id of selected ?? [])
|
||||||
|
if (contract && !operations(id, contract)) fail(portPath, `Unknown selected operation ${id}`);
|
||||||
|
if (entry.execution && entry.execution.kind !== "remote") {
|
||||||
|
if (!selected) fail(portPath, "Portable interface ports must select their required operations");
|
||||||
|
for (const id of selected ?? [])
|
||||||
|
if (contract) supports(entry.execution, operations(id, contract)?.execution, portPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!entry.execution || entry.execution.kind === "remote") continue;
|
||||||
|
if (requirement.kind === "state" || requirement.kind === "edge") {
|
||||||
|
const read = requirement.kind === "state" ? "read" : "resolve";
|
||||||
|
for (const primitive of requirement.primitives) {
|
||||||
|
if (primitive.startsWith("watch"))
|
||||||
|
fail(portPath, "Portable operations cannot acquire watch lifecycle primitives");
|
||||||
|
else if (entry.execution.kind === "snapshot" && primitive !== read)
|
||||||
|
fail(portPath, "Snapshot operations cannot acquire write primitives");
|
||||||
|
}
|
||||||
|
} else if (requirement.kind === "query") {
|
||||||
|
const query = packages.get(requirement.packageRevisionId)?.queries?.find((q) => q.id === requirement.queryId);
|
||||||
|
if (query?.placement?.kind !== "local")
|
||||||
|
fail(portPath, "Portable operations require a local query dependency");
|
||||||
|
} else if (requirement.kind === "creation") {
|
||||||
|
if (entry.execution.kind !== "transaction") fail(portPath, "Object creation requires transaction execution");
|
||||||
|
} else if (requirement.kind === "reflection") {
|
||||||
|
if (requirement.selectors.some((selector) => selector !== "presentations"))
|
||||||
|
fail(portPath, "Only checked presentation metadata is portable");
|
||||||
|
} else if (requirement.kind !== "interface")
|
||||||
|
fail(portPath, `Portable execution does not support ${requirement.kind} dependencies`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const query of revision.queries ?? [])
|
||||||
|
if (query.placement?.kind === "local") {
|
||||||
|
const sourceQueryId = query.placement.sourceQueryId;
|
||||||
|
const source = revision.queries?.find((q) => q.id === sourceQueryId);
|
||||||
|
if (source?.placement?.kind !== "replica")
|
||||||
|
fail(`${revision.revisionId}/${query.id}`, "Local queries must name a replica query in the same package");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const conformance of workspace.conformances) {
|
||||||
|
const revision = interfaces.get(conformance.interfaceRevisionId);
|
||||||
|
if (!revision) continue;
|
||||||
|
for (const entry of conformance.operationBindings) {
|
||||||
|
const operation = operations(entry.operationId, revision);
|
||||||
|
if (!operation) continue;
|
||||||
|
const path = `${conformance.atomId}/${revision.revisionId}/${entry.operationId}`;
|
||||||
|
let binding;
|
||||||
|
try {
|
||||||
|
binding = resolveForwardedBinding(
|
||||||
|
workspace,
|
||||||
|
conformance.atomId,
|
||||||
|
revision.revisionId,
|
||||||
|
entry.operationId,
|
||||||
|
).binding;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
} // Ordinary binding validation reports unresolved references and cycles.
|
||||||
|
if (entry.binding.kind === "interface") {
|
||||||
|
const target = interfaces.get(entry.binding.interfaceRevisionId);
|
||||||
|
if (target) supports(operation.execution, operations(entry.binding.operationId, target)?.execution, path);
|
||||||
|
}
|
||||||
|
if (binding.kind === "lifecycle") {
|
||||||
|
if (
|
||||||
|
operation.execution?.kind !== "transaction" ||
|
||||||
|
operation.execution.targets.length !== 1 ||
|
||||||
|
operation.execution.targets[0] !== "server"
|
||||||
|
)
|
||||||
|
fail(
|
||||||
|
path,
|
||||||
|
"Deletion requires an explicitly server-only transaction; partial replicas cannot enumerate incoming references",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!operation.execution || operation.execution.kind === "remote") continue;
|
||||||
|
if (binding.kind === "journal") {
|
||||||
|
const expected = binding.primitive === "publish" ? "transaction" : "snapshot";
|
||||||
|
if (operation.execution.kind !== expected) fail(path, "Native journal operation requires the matching effect");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (binding.kind === "subscription") {
|
||||||
|
const expected = binding.primitive === "status" ? "snapshot" : "transaction";
|
||||||
|
if (
|
||||||
|
operation.execution.kind !== expected ||
|
||||||
|
(binding.primitive !== "status" && operation.execution.targets.some((target) => target !== "server"))
|
||||||
|
)
|
||||||
|
fail(path, "Subscription controls require authoritative execution; status is a portable snapshot");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (binding.kind === "package") {
|
||||||
|
const provider = packages.get(binding.packageRevisionId)?.exports.find((e) => e.id === binding.exportId);
|
||||||
|
supports(operation.execution, provider?.execution, path);
|
||||||
|
} else {
|
||||||
|
const read = binding.kind === "state" ? "read" : "resolve";
|
||||||
|
if (binding.primitive.startsWith("watch"))
|
||||||
|
fail(path, "Portable operations cannot bind watch lifecycle primitives");
|
||||||
|
else if (operation.execution.kind === "snapshot" && binding.primitive !== read)
|
||||||
|
fail(path, "Snapshot operations cannot bind write primitives");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type {
|
||||||
|
AtomId,
|
||||||
|
Binding,
|
||||||
|
Conformance,
|
||||||
|
InterfaceRevisionId,
|
||||||
|
OperationBinding,
|
||||||
|
OperationId,
|
||||||
|
WorkspaceRevision,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export type ConcreteBinding = Exclude<Binding, { kind: "interface" }>;
|
||||||
|
export type ResolvedBinding = { conformance: Conformance; entry: OperationBinding; binding: ConcreteBinding };
|
||||||
|
|
||||||
|
/** Follow public operations on the same receiver, never private attachment names. */
|
||||||
|
export function resolveForwardedBinding(
|
||||||
|
workspace: Pick<WorkspaceRevision, "conformances">,
|
||||||
|
atomId: AtomId,
|
||||||
|
interfaceRevisionId: InterfaceRevisionId,
|
||||||
|
operationId: OperationId,
|
||||||
|
): ResolvedBinding {
|
||||||
|
const visited = new Set<string>();
|
||||||
|
for (;;) {
|
||||||
|
const key = `${interfaceRevisionId}\0${operationId}`;
|
||||||
|
if (visited.has(key)) throw new Error(`Forwarding cycle at ${interfaceRevisionId}/${operationId}`);
|
||||||
|
visited.add(key);
|
||||||
|
const conformance = workspace.conformances.find(
|
||||||
|
(c) => c.atomId === atomId && c.interfaceRevisionId === interfaceRevisionId,
|
||||||
|
);
|
||||||
|
const entry = conformance?.operationBindings.find((b) => b.operationId === operationId);
|
||||||
|
if (!conformance || !entry)
|
||||||
|
throw new Error(`Missing forwarding target ${atomId} as ${interfaceRevisionId}/${operationId}`);
|
||||||
|
if (entry.binding.kind !== "interface") return { conformance, entry, binding: entry.binding };
|
||||||
|
interfaceRevisionId = entry.binding.interfaceRevisionId;
|
||||||
|
operationId = entry.binding.operationId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
|
|
||||||
export type GenericDependencyPort = Omit<DependencyPort, "requirement"> & {
|
export type GenericDependencyPort = Omit<DependencyPort, "requirement"> & {
|
||||||
requirement:
|
requirement:
|
||||||
|
| Extract<DependencyPort["requirement"], { kind: "query" | "reflection" }>
|
||||||
| {
|
| {
|
||||||
kind: "state";
|
kind: "state";
|
||||||
valueType: ValueTypeExpression;
|
valueType: ValueTypeExpression;
|
||||||
@@ -31,13 +32,21 @@ export type GenericDependencyPort = Omit<DependencyPort, "requirement"> & {
|
|||||||
kind: "edge";
|
kind: "edge";
|
||||||
target: ObjectTypeExpression;
|
target: ObjectTypeExpression;
|
||||||
cardinality: Extract<DependencyPort["requirement"], { kind: "edge" }>["cardinality"];
|
cardinality: Extract<DependencyPort["requirement"], { kind: "edge" }>["cardinality"];
|
||||||
|
keyType?: "string" | "boolean" | "int64";
|
||||||
primitives: Extract<DependencyPort["requirement"], { kind: "edge" }>["primitives"];
|
primitives: Extract<DependencyPort["requirement"], { kind: "edge" }>["primitives"];
|
||||||
}
|
}
|
||||||
| { kind: "interface"; application: InterfaceApplicationExpression }
|
| {
|
||||||
|
kind: "interface";
|
||||||
|
application: InterfaceApplicationExpression;
|
||||||
|
operations?: import("./types.js").OperationId[];
|
||||||
|
receiver?: "input";
|
||||||
|
}
|
||||||
|
| { kind: "creation"; target: ObjectTypeExpression; inputType: ValueTypeExpression }
|
||||||
| { kind: "constructor"; target: ObjectTypeExpression; inputType: ValueTypeExpression };
|
| { kind: "constructor"; target: ObjectTypeExpression; inputType: ValueTypeExpression };
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface GenericPackageExport {
|
export interface GenericPackageExport {
|
||||||
|
execution?: import("./types.js").ExecutionContract;
|
||||||
id: PackageExportId;
|
id: PackageExportId;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
parameters: TypeParameter[];
|
parameters: TypeParameter[];
|
||||||
@@ -67,9 +76,8 @@ export const specializePackageExport = (
|
|||||||
const digest = createHash("sha256")
|
const digest = createHash("sha256")
|
||||||
.update(
|
.update(
|
||||||
JSON.stringify([
|
JSON.stringify([
|
||||||
"quixos-package-specialization-v1",
|
"quixos-package-specialization-v2",
|
||||||
pkg.revisionId,
|
pkg.revisionId,
|
||||||
[pkg.source.repository, pkg.source.commit],
|
|
||||||
definition.id,
|
definition.id,
|
||||||
arguments_.map(canonicalTypeArgument),
|
arguments_.map(canonicalTypeArgument),
|
||||||
environment.self ?? null,
|
environment.self ?? null,
|
||||||
@@ -79,6 +87,9 @@ export const specializePackageExport = (
|
|||||||
const ports: DependencyPort[] = definition.dependencyPorts.map((port) => {
|
const ports: DependencyPort[] = definition.dependencyPorts.map((port) => {
|
||||||
const requirement = port.requirement;
|
const requirement = port.requirement;
|
||||||
switch (requirement.kind) {
|
switch (requirement.kind) {
|
||||||
|
case "reflection":
|
||||||
|
case "query":
|
||||||
|
return { ...port, requirement };
|
||||||
case "state":
|
case "state":
|
||||||
return { ...port, requirement: { ...requirement, valueType: substitution.value(requirement.valueType) } };
|
return { ...port, requirement: { ...requirement, valueType: substitution.value(requirement.valueType) } };
|
||||||
case "edge":
|
case "edge":
|
||||||
@@ -86,8 +97,14 @@ export const specializePackageExport = (
|
|||||||
case "interface":
|
case "interface":
|
||||||
return {
|
return {
|
||||||
...port,
|
...port,
|
||||||
requirement: { kind: "interface", interfaceRevisionId: substitution.application(requirement.application) },
|
requirement: {
|
||||||
|
kind: "interface",
|
||||||
|
interfaceRevisionId: substitution.application(requirement.application),
|
||||||
|
...(requirement.operations ? { operations: requirement.operations } : {}),
|
||||||
|
...(requirement.receiver ? { receiver: requirement.receiver } : {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
case "creation":
|
||||||
case "constructor": {
|
case "constructor": {
|
||||||
const target = substitution.object(requirement.target);
|
const target = substitution.object(requirement.target);
|
||||||
if (target.kind !== "atom")
|
if (target.kind !== "atom")
|
||||||
@@ -99,7 +116,7 @@ export const specializePackageExport = (
|
|||||||
return {
|
return {
|
||||||
...port,
|
...port,
|
||||||
requirement: {
|
requirement: {
|
||||||
kind: "constructor",
|
kind: requirement.kind,
|
||||||
atomId: target.atomId,
|
atomId: target.atomId,
|
||||||
inputType: substitution.value(requirement.inputType),
|
inputType: substitution.value(requirement.inputType),
|
||||||
},
|
},
|
||||||
@@ -108,6 +125,7 @@ export const specializePackageExport = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const common = {
|
const common = {
|
||||||
|
...(definition.execution ? { execution: structuredClone(definition.execution) } : {}),
|
||||||
id: capabilityId.packageExport(`export-application:sha256:${digest}`),
|
id: capabilityId.packageExport(`export-application:sha256:${digest}`),
|
||||||
displayName: `${definition.displayName}$${digest}`,
|
displayName: `${definition.displayName}$${digest}`,
|
||||||
inputType: substitution.value(definition.inputType),
|
inputType: substitution.value(definition.inputType),
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export type ValueTypeExpression =
|
|||||||
| Extract<ValueType, { kind: "builtin" | "scalar" | "message" }>
|
| Extract<ValueType, { kind: "builtin" | "scalar" | "message" }>
|
||||||
| { kind: "parameter"; parameterId: string }
|
| { kind: "parameter"; parameterId: string }
|
||||||
| { kind: "record"; fields: Record<string, ValueTypeExpression> }
|
| { kind: "record"; fields: Record<string, ValueTypeExpression> }
|
||||||
| { kind: "list" | "optional"; value: ValueTypeExpression }
|
| { kind: "variant"; id: string; cases: Record<string, { id: string; value: ValueTypeExpression }> }
|
||||||
|
| { kind: "list" | "optional" | "change"; value: ValueTypeExpression }
|
||||||
| { kind: "object-ref"; expectation: ObjectTypeExpression }
|
| { kind: "object-ref"; expectation: ObjectTypeExpression }
|
||||||
| { kind: "alias"; definitionId: string; arguments: TypeArgumentExpression[] };
|
| { kind: "alias"; definitionId: string; arguments: TypeArgumentExpression[] };
|
||||||
|
|
||||||
@@ -86,15 +87,30 @@ export const instantiateInterface = (
|
|||||||
const members: InterfaceMember[] = template.members.map((member) => {
|
const members: InterfaceMember[] = template.members.map((member) => {
|
||||||
const common = { id: member.id, displayName: member.displayName, operations: member.operations.map(operation) };
|
const common = { id: member.id, displayName: member.displayName, operations: member.operations.map(operation) };
|
||||||
switch (member.kind) {
|
switch (member.kind) {
|
||||||
|
case "module":
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
kind: "module",
|
||||||
|
contract: member.contract,
|
||||||
|
...(member.subject ? { subject: substitution.object(member.subject, member.displayName) } : {}),
|
||||||
|
};
|
||||||
case "value":
|
case "value":
|
||||||
return { ...common, kind: "value", valueType: substitution.value(member.valueType, member.displayName) };
|
return {
|
||||||
|
...common,
|
||||||
|
kind: "value",
|
||||||
|
...(member.nativeField ? { nativeField: true as const } : {}),
|
||||||
|
...(member.queryRead ? { queryRead: { ...member.queryRead } } : {}),
|
||||||
|
valueType: substitution.value(member.valueType, member.displayName),
|
||||||
|
};
|
||||||
case "relationship":
|
case "relationship":
|
||||||
return {
|
return {
|
||||||
...common,
|
...common,
|
||||||
kind: "relationship",
|
kind: "relationship",
|
||||||
|
...(member.queryRead ? { queryRead: { ...member.queryRead } } : {}),
|
||||||
target: substitution.object(member.target, member.displayName),
|
target: substitution.object(member.target, member.displayName),
|
||||||
cardinality: member.cardinality,
|
cardinality: member.cardinality,
|
||||||
ordered: member.ordered,
|
ordered: member.ordered,
|
||||||
|
...(member.keyType ? { keyType: member.keyType } : {}),
|
||||||
};
|
};
|
||||||
case "operation":
|
case "operation":
|
||||||
return {
|
return {
|
||||||
@@ -177,10 +193,12 @@ export const isStorableType = (
|
|||||||
case "list":
|
case "list":
|
||||||
case "optional":
|
case "optional":
|
||||||
return visit(value.value, depth + 1);
|
return visit(value.value, depth + 1);
|
||||||
// Records currently have RPC codecs, not ordinary-state persistence codecs.
|
case "change":
|
||||||
// A generic bound must not promise storage that the installed model rejects.
|
|
||||||
case "record":
|
|
||||||
return false;
|
return false;
|
||||||
|
case "record":
|
||||||
|
return Object.values(value.fields).every((field) => visit(field, depth + 1));
|
||||||
|
case "variant":
|
||||||
|
return Object.values(value.cases).every((entry) => visit(entry.value, depth + 1));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return visit(type, 0);
|
return visit(type, 0);
|
||||||
@@ -207,8 +225,17 @@ export const canonicalTypeArgument = (argument: ClosedTypeArgument): string => {
|
|||||||
return [value.kind, value.name];
|
return [value.kind, value.name];
|
||||||
case "message":
|
case "message":
|
||||||
return ["message", value.descriptorId];
|
return ["message", value.descriptorId];
|
||||||
|
case "variant":
|
||||||
|
return [
|
||||||
|
"variant",
|
||||||
|
value.id,
|
||||||
|
Object.keys(value.cases)
|
||||||
|
.sort()
|
||||||
|
.map((label) => [label, value.cases[label].id, type(value.cases[label].value, depth + 1)]),
|
||||||
|
];
|
||||||
case "object-ref":
|
case "object-ref":
|
||||||
return ["object-ref", target(value.expectation)];
|
return ["object-ref", target(value.expectation)];
|
||||||
|
case "change":
|
||||||
case "list":
|
case "list":
|
||||||
case "optional":
|
case "optional":
|
||||||
return [value.kind, type(value.value, depth + 1)];
|
return [value.kind, type(value.value, depth + 1)];
|
||||||
@@ -243,10 +270,8 @@ export interface AppliedInterfaceIdentity {
|
|||||||
|
|
||||||
export const appliedInterfaceId = (application: AppliedInterfaceIdentity): InterfaceRevisionId => {
|
export const appliedInterfaceId = (application: AppliedInterfaceIdentity): InterfaceRevisionId => {
|
||||||
const encoding = JSON.stringify([
|
const encoding = JSON.stringify([
|
||||||
"quixos-applied-interface-v1",
|
"quixos-applied-interface-v2",
|
||||||
application.definitionId,
|
application.definitionId,
|
||||||
application.source.repository,
|
|
||||||
application.source.commit.toLowerCase(),
|
|
||||||
application.arguments.map(canonicalTypeArgument),
|
application.arguments.map(canonicalTypeArgument),
|
||||||
application.self ?? null,
|
application.self ?? null,
|
||||||
]);
|
]);
|
||||||
@@ -310,12 +335,24 @@ export class TypeSubstitution {
|
|||||||
value(expression: ValueTypeExpression, path = "type", depth = 0): ValueType {
|
value(expression: ValueTypeExpression, path = "type", depth = 0): ValueType {
|
||||||
this.budget.enter(path, depth);
|
this.budget.enter(path, depth);
|
||||||
switch (expression.kind) {
|
switch (expression.kind) {
|
||||||
|
case "variant":
|
||||||
|
return {
|
||||||
|
kind: "variant",
|
||||||
|
id: expression.id,
|
||||||
|
cases: Object.fromEntries(
|
||||||
|
Object.entries(expression.cases).map(([label, entry]) => [
|
||||||
|
label,
|
||||||
|
{ id: entry.id, value: this.value(entry.value, `${path}.${label}`, depth + 1) },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
case "builtin":
|
case "builtin":
|
||||||
return { kind: "builtin", name: expression.name };
|
return { kind: "builtin", name: expression.name };
|
||||||
case "scalar":
|
case "scalar":
|
||||||
return { kind: "scalar", name: expression.name };
|
return { kind: "scalar", name: expression.name };
|
||||||
case "message":
|
case "message":
|
||||||
return { kind: "message", descriptorId: expression.descriptorId };
|
return { kind: "message", descriptorId: expression.descriptorId };
|
||||||
|
case "change":
|
||||||
case "list":
|
case "list":
|
||||||
case "optional":
|
case "optional":
|
||||||
return { kind: expression.kind, value: this.value(expression.value, `${path}.${expression.kind}`, depth + 1) };
|
return { kind: expression.kind, value: this.value(expression.value, `${path}.${expression.kind}`, depth + 1) };
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from "./evolution.js";
|
|||||||
export * from "./migrations.js";
|
export * from "./migrations.js";
|
||||||
export * from "./generics.js";
|
export * from "./generics.js";
|
||||||
export * from "./generic-packages.js";
|
export * from "./generic-packages.js";
|
||||||
|
export * from "./modules.js";
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type {
|
||||||
|
BoundDependency,
|
||||||
|
Conformance,
|
||||||
|
DependencyPort,
|
||||||
|
InitializationField,
|
||||||
|
ValueType,
|
||||||
|
WorkspaceRevision,
|
||||||
|
} from "./types.js";
|
||||||
|
import type { CapabilityValidationIssue } from "./validation.js";
|
||||||
|
import { cardinalityValueType, valueTypesEqual } from "./validation.js";
|
||||||
|
|
||||||
|
/** Creation grants seed inputs on new objects, never general private-state authority. */
|
||||||
|
export function validateInitialization(workspace: WorkspaceRevision): CapabilityValidationIssue[] {
|
||||||
|
const issues: CapabilityValidationIssue[] = [];
|
||||||
|
const fail = (path: string, message: string) => issues.push({ code: "invalid-initialization", path, message });
|
||||||
|
const ownerKey = (owner: Conformance) => `${owner.atomId}/${owner.interfaceRevisionId}`;
|
||||||
|
const shapes = new Map<string, ValueType>();
|
||||||
|
const fieldType = (owner: Conformance, field: InitializationField): ValueType | undefined => {
|
||||||
|
const binding = field.binding;
|
||||||
|
const attachment = owner.privateAttachments.find(
|
||||||
|
(a) => a.kind === binding.kind && a.id === (binding.kind === "state" ? binding.slotId : binding.edgeTypeId),
|
||||||
|
);
|
||||||
|
if (!attachment) {
|
||||||
|
fail(ownerKey(owner), `Initialization input ${field.input} must name an attachment owned by this conformance`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attachment.kind === "state") {
|
||||||
|
if (["journal", "subscription"].includes(attachment.storagePolicy.kind)) {
|
||||||
|
fail(ownerKey(owner), "Native journals start empty and cannot be seeded as state");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return attachment.valueType;
|
||||||
|
}
|
||||||
|
if (binding.kind !== "edge") return;
|
||||||
|
const index = attachment.endpoints.findIndex((e) => e.projectionId === binding.projectionId);
|
||||||
|
const endpoint = attachment.endpoints[index];
|
||||||
|
const target = attachment.endpoints[1 - index];
|
||||||
|
if (!endpoint || !target || endpoint.constraint.kind !== "atom" || endpoint.constraint.atomId !== owner.atomId) {
|
||||||
|
fail(ownerKey(owner), `Initialization input ${field.input} must name an edge projection on the newborn atom`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.keyType) {
|
||||||
|
fail(
|
||||||
|
ownerKey(owner),
|
||||||
|
"Initialize a keyed relationship through its keyed projection; inverse seeds cannot supply a map key",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (endpoint.cardinality === "many" || endpoint.cardinality === "many-unique") {
|
||||||
|
const fields: Record<string, ValueType> = {
|
||||||
|
key: { kind: "scalar", name: "string" },
|
||||||
|
target: cardinalityValueType(target.constraint, "exactly-one"),
|
||||||
|
};
|
||||||
|
if (endpoint.keyType)
|
||||||
|
fields.mapKey = { kind: "scalar", name: endpoint.keyType === "boolean" ? "bool" : endpoint.keyType };
|
||||||
|
return { kind: "list", value: { kind: "record", fields } };
|
||||||
|
}
|
||||||
|
return cardinalityValueType(target.constraint, endpoint.cardinality);
|
||||||
|
};
|
||||||
|
for (const owner of workspace.conformances) {
|
||||||
|
if (!owner.initialization) continue;
|
||||||
|
const fields: Record<string, ValueType> = Object.create(null);
|
||||||
|
const destinations = new Set<string>();
|
||||||
|
for (const field of owner.initialization) {
|
||||||
|
const path = `${ownerKey(owner)}/${field.input}`;
|
||||||
|
if (!field.input || Object.hasOwn(fields, field.input))
|
||||||
|
fail(path, "Initialization input names must be nonempty and distinct");
|
||||||
|
const destination = JSON.stringify(field.binding);
|
||||||
|
if (destinations.has(destination)) fail(path, "An attachment may have only one initialization input");
|
||||||
|
destinations.add(destination);
|
||||||
|
const type = fieldType(owner, field);
|
||||||
|
if (type) fields[field.input] = type;
|
||||||
|
}
|
||||||
|
shapes.set(ownerKey(owner), { kind: "record", fields });
|
||||||
|
}
|
||||||
|
const checkBindings = (ports: DependencyPort[], dependencies: BoundDependency[], path: string) => {
|
||||||
|
for (const dependency of dependencies) {
|
||||||
|
if (dependency.binding.kind !== "creation") continue;
|
||||||
|
const binding = dependency.binding;
|
||||||
|
const requirement = ports.find((p) => p.id === dependency.portId)?.requirement;
|
||||||
|
if (requirement?.kind !== "creation") continue; // General binding validation reports the kind mismatch.
|
||||||
|
const portPath = `${path}/${dependency.portId}`;
|
||||||
|
if (binding.atomId !== requirement.atomId) fail(portPath, `Creation port requires atom ${requirement.atomId}`);
|
||||||
|
const initialized = new Set<string>();
|
||||||
|
const supplied = new Set<string>();
|
||||||
|
for (const mapping of binding.owners) {
|
||||||
|
const key = `${binding.atomId}/${mapping.interfaceRevisionId}`;
|
||||||
|
if (supplied.has(key)) fail(portPath, `Duplicate initialization owner ${key}`);
|
||||||
|
supplied.add(key);
|
||||||
|
const owner = workspace.conformances.find((o) => ownerKey(o) === key);
|
||||||
|
const shape = shapes.get(key);
|
||||||
|
if (!owner || !shape) {
|
||||||
|
fail(portPath, `Owner ${key} has no declared initialization mapping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let seed: ValueType | undefined = requirement.inputType;
|
||||||
|
for (const component of mapping.inputPath) seed = seed?.kind === "record" ? seed.fields[component] : undefined;
|
||||||
|
if (!seed || !valueTypesEqual(seed, shape))
|
||||||
|
fail(
|
||||||
|
portPath,
|
||||||
|
`Seed input${mapping.inputPath.map((p) => `.${p}`).join("")} does not match initialization inputs of ${key}`,
|
||||||
|
);
|
||||||
|
for (const field of owner.initialization!)
|
||||||
|
if (field.binding.kind === "state") initialized.add(field.binding.slotId);
|
||||||
|
}
|
||||||
|
const states = [
|
||||||
|
...workspace.sharedAttachments,
|
||||||
|
...workspace.conformances.flatMap((o) => o.privateAttachments),
|
||||||
|
].filter((a) => a.kind === "state" && a.attachedTo === binding.atomId);
|
||||||
|
for (const state of states)
|
||||||
|
if (
|
||||||
|
state.kind === "state" &&
|
||||||
|
!["journal", "subscription"].includes(state.storagePolicy.kind) &&
|
||||||
|
state.defaultValue === undefined &&
|
||||||
|
!initialized.has(state.id)
|
||||||
|
)
|
||||||
|
fail(portPath, `Required state ${state.id} has no default or declared seed mapping`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const exports = new Map(
|
||||||
|
workspace.packageImports.flatMap((p) => p.exports.map((e) => [`${p.revisionId}/${e.id}`, e] as const)),
|
||||||
|
);
|
||||||
|
for (const owner of workspace.conformances)
|
||||||
|
for (const operation of owner.operationBindings) {
|
||||||
|
if (operation.binding.kind !== "package") continue;
|
||||||
|
const entry = exports.get(`${operation.binding.packageRevisionId}/${operation.binding.exportId}`);
|
||||||
|
if (entry)
|
||||||
|
checkBindings(
|
||||||
|
entry.dependencyPorts,
|
||||||
|
operation.binding.dependencies,
|
||||||
|
`${ownerKey(owner)}/${operation.operationId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const constructor of workspace.constructors) {
|
||||||
|
const entry = exports.get(`${constructor.packageRevisionId}/${constructor.exportId}`);
|
||||||
|
if (entry) checkBindings(entry.dependencyPorts, constructor.dependencies, `constructor/${constructor.atomId}`);
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import {
|
||||||
|
valueType,
|
||||||
|
type JournalPrimitive,
|
||||||
|
type InterfaceOperation,
|
||||||
|
type InterfaceRevision,
|
||||||
|
type ObjectExpectation,
|
||||||
|
type OperationId,
|
||||||
|
type ValueType,
|
||||||
|
} from "./types.js";
|
||||||
|
import { valueTypesEqual } from "./validation.js";
|
||||||
|
|
||||||
|
export const subscriptionStatusType: ValueType = {
|
||||||
|
kind: "record",
|
||||||
|
fields: {
|
||||||
|
status: valueType.string,
|
||||||
|
generation: valueType.uint64,
|
||||||
|
cursor: valueType.uint64,
|
||||||
|
failure: {
|
||||||
|
kind: "optional",
|
||||||
|
value: {
|
||||||
|
kind: "record",
|
||||||
|
fields: { publication: valueType.string, code: valueType.string, message: valueType.string },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Native subscription configuration is selected by the checked attach contract.
|
||||||
|
* No library/interface ID or handler method name is reserved by Camino. */
|
||||||
|
export function subscriptionContract(
|
||||||
|
payload: ValueType,
|
||||||
|
parameters: ValueType,
|
||||||
|
attach: InterfaceOperation,
|
||||||
|
interfaces: readonly InterfaceRevision[],
|
||||||
|
): { source: ObjectExpectation; handler: ObjectExpectation; operation: OperationId } {
|
||||||
|
const fail = (message: string): never => {
|
||||||
|
throw new Error(message);
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
attach.mode !== "call" ||
|
||||||
|
attach.scope === "class" ||
|
||||||
|
!valueTypesEqual(attach.outputType, valueType.unit) ||
|
||||||
|
attach.inputType.kind !== "record"
|
||||||
|
)
|
||||||
|
return fail("Subscription attachment must be an object call returning unit");
|
||||||
|
const fields = attach.inputType.fields;
|
||||||
|
if (
|
||||||
|
Object.keys(fields).sort().join(",") !== "handler,parameters,source,start" ||
|
||||||
|
fields.source?.kind !== "object-ref" ||
|
||||||
|
fields.handler?.kind !== "object-ref" ||
|
||||||
|
fields.handler.expectation.kind !== "interface" ||
|
||||||
|
!valueTypesEqual(fields.parameters!, parameters)
|
||||||
|
)
|
||||||
|
return fail("Attachment needs typed source/handler references, matching parameters, and a start policy");
|
||||||
|
const start = fields.start;
|
||||||
|
if (
|
||||||
|
start?.kind !== "variant" ||
|
||||||
|
Object.keys(start.cases).sort().join(",") !== "afterCursor,future,retained" ||
|
||||||
|
!valueTypesEqual(start.cases.future!.value, valueType.unit) ||
|
||||||
|
!valueTypesEqual(start.cases.retained!.value, valueType.unit) ||
|
||||||
|
!valueTypesEqual(start.cases.afterCursor!.value, valueType.string)
|
||||||
|
)
|
||||||
|
return fail("Subscription start requires future:unit, retained:unit and afterCursor:string variant cases");
|
||||||
|
const handler = fields.handler.expectation;
|
||||||
|
const expected: ValueType = { kind: "record", fields: { event: payload, parameters } };
|
||||||
|
const candidates =
|
||||||
|
interfaces
|
||||||
|
.find((iface) => iface.revisionId === handler.interfaceRevisionId)
|
||||||
|
?.members.flatMap((member) => member.operations)
|
||||||
|
.filter(
|
||||||
|
(operation) =>
|
||||||
|
operation.mode === "call" &&
|
||||||
|
operation.scope !== "class" &&
|
||||||
|
operation.execution?.kind === "transaction" &&
|
||||||
|
operation.execution.targets.includes("server") &&
|
||||||
|
valueTypesEqual(operation.inputType, expected) &&
|
||||||
|
valueTypesEqual(operation.outputType, valueType.unit),
|
||||||
|
) ?? [];
|
||||||
|
if (candidates.length !== 1)
|
||||||
|
return fail("Handler interface must select one unambiguous server transaction from {event, parameters} to unit");
|
||||||
|
return { source: fields.source.expectation, handler, operation: candidates[0]!.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Journals persist checked values, including managed references. They cannot
|
||||||
|
* persist process handles, opaque messages or edit commands. */
|
||||||
|
export function journalPayloadType(type: ValueType): boolean {
|
||||||
|
switch (type.kind) {
|
||||||
|
case "scalar":
|
||||||
|
case "object-ref":
|
||||||
|
return true;
|
||||||
|
case "builtin":
|
||||||
|
return type.name === "unit";
|
||||||
|
case "list":
|
||||||
|
case "optional":
|
||||||
|
return journalPayloadType(type.value);
|
||||||
|
case "record":
|
||||||
|
return Object.values(type.fields).every(journalPayloadType);
|
||||||
|
case "variant":
|
||||||
|
return Object.values(type.cases).every((entry) => journalPayloadType(entry.value));
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function journalPrimitiveSignature(
|
||||||
|
payload: ValueType,
|
||||||
|
primitive: JournalPrimitive,
|
||||||
|
): Pick<InterfaceOperation, "mode" | "inputType" | "outputType"> {
|
||||||
|
const optional = (value: ValueType): ValueType => ({ kind: "optional", value });
|
||||||
|
if (primitive === "publish")
|
||||||
|
return {
|
||||||
|
mode: "call",
|
||||||
|
inputType: payload,
|
||||||
|
outputType: { kind: "record", fields: { publication: valueType.string } },
|
||||||
|
};
|
||||||
|
if (primitive === "head") return { mode: "call", inputType: valueType.unit, outputType: optional(valueType.uint64) };
|
||||||
|
return {
|
||||||
|
mode: "call",
|
||||||
|
inputType: { kind: "record", fields: { after: optional(valueType.string), limit: valueType.uint32 } },
|
||||||
|
outputType: {
|
||||||
|
kind: "record",
|
||||||
|
fields: {
|
||||||
|
entries: {
|
||||||
|
kind: "list",
|
||||||
|
value: { kind: "record", fields: { publication: valueType.string, position: valueType.uint64, payload } },
|
||||||
|
},
|
||||||
|
next: optional(valueType.string),
|
||||||
|
asOfHead: optional(valueType.uint64),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { InterfaceRevision, ModuleInterfaceMember, PackageModule, WorkspaceRevision } from "./types.js";
|
||||||
|
import type { CapabilityValidationIssue } from "./validation.js";
|
||||||
|
|
||||||
|
/** Public nominal contract lookup; does not resolve an executable artifact or grant access. */
|
||||||
|
export function moduleContract(
|
||||||
|
interfaces: readonly InterfaceRevision[],
|
||||||
|
module: PackageModule,
|
||||||
|
): ModuleInterfaceMember | undefined {
|
||||||
|
const iface = interfaces.find((entry) => entry.revisionId === module.interfaceRevisionId);
|
||||||
|
if (!iface || iface.template) return undefined;
|
||||||
|
const member = iface.members.find((entry) => entry.id === module.memberId);
|
||||||
|
return member?.kind === "module" ? member : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateModules(workspace: WorkspaceRevision): CapabilityValidationIssue[] {
|
||||||
|
const issues: CapabilityValidationIssue[] = [];
|
||||||
|
const fail = (path: string, message: string) => issues.push({ code: "invalid-module-contract", path, message });
|
||||||
|
for (const iface of workspace.interfaceImports) {
|
||||||
|
for (const member of iface.template?.members ?? iface.members) {
|
||||||
|
if (member.kind !== "module") continue;
|
||||||
|
const path = `${iface.revisionId}.${member.id}`;
|
||||||
|
if (!/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+\/[1-9][0-9]*$/.test(member.contract))
|
||||||
|
fail(path, "Module contracts require a namespaced, versioned identifier");
|
||||||
|
if (member.operations.length) fail(path, "A module member cannot declare callable operations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const pkg of workspace.packageImports) {
|
||||||
|
const ids = new Set<string>(pkg.exports.map((entry) => entry.id));
|
||||||
|
const names = new Set([
|
||||||
|
...pkg.exports.map((entry) => entry.displayName),
|
||||||
|
...(pkg.genericExports ?? []).map((entry) => entry.displayName),
|
||||||
|
...(pkg.queries ?? []).map((entry) => entry.displayName),
|
||||||
|
...(pkg.queryTemplates ?? []).map((entry) => entry.declaration.displayName),
|
||||||
|
]);
|
||||||
|
for (const module of pkg.modules ?? []) {
|
||||||
|
const path = `${pkg.revisionId}.${module.id}`;
|
||||||
|
if (!module.id.trim() || !module.displayName.trim() || ids.has(module.id) || names.has(module.displayName))
|
||||||
|
fail(path, "Duplicate or empty module identity or package member name");
|
||||||
|
ids.add(module.id);
|
||||||
|
names.add(module.displayName);
|
||||||
|
if (!moduleContract(workspace.interfaceImports, module))
|
||||||
|
fail(path, "Module export must implement a closed imported module member");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const conformance of workspace.conformances) {
|
||||||
|
const iface = workspace.interfaceImports.find((entry) => entry.revisionId === conformance.interfaceRevisionId);
|
||||||
|
if (!iface) continue;
|
||||||
|
const bound = new Set<string>();
|
||||||
|
for (const binding of conformance.moduleBindings ?? []) {
|
||||||
|
const path = `${conformance.id ?? conformance.atomId}.${binding.memberId}`;
|
||||||
|
const member = iface.members.find((entry) => entry.id === binding.memberId);
|
||||||
|
const pkg = workspace.packageImports.find((entry) => entry.revisionId === binding.packageRevisionId);
|
||||||
|
const module = pkg?.modules?.find((entry) => entry.id === binding.moduleId);
|
||||||
|
if (bound.has(binding.memberId)) fail(path, "Module member is bound more than once");
|
||||||
|
bound.add(binding.memberId);
|
||||||
|
if (
|
||||||
|
member?.kind !== "module" ||
|
||||||
|
!module ||
|
||||||
|
module.interfaceRevisionId !== iface.revisionId ||
|
||||||
|
module.memberId !== member.id
|
||||||
|
) {
|
||||||
|
fail(path, "Module binding must implement this exact nominal interface member");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const subject = member.subject;
|
||||||
|
if (
|
||||||
|
subject &&
|
||||||
|
!(subject.kind === "atom"
|
||||||
|
? subject.atomId === conformance.atomId
|
||||||
|
: workspace.conformances.some(
|
||||||
|
(entry) =>
|
||||||
|
entry.atomId === conformance.atomId && entry.interfaceRevisionId === subject.interfaceRevisionId,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
fail(path, "Implementing atom does not satisfy the module subject contract");
|
||||||
|
}
|
||||||
|
for (const member of iface.members)
|
||||||
|
if (member.kind === "module" && !bound.has(member.id))
|
||||||
|
fail(`${conformance.id ?? conformance.atomId}.${member.id}`, "Missing module member binding");
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { WorkspaceRevision } from "./types.js";
|
||||||
|
import type { CapabilityValidationIssue } from "./validation.js";
|
||||||
|
import { moduleContract } from "./modules.js";
|
||||||
|
|
||||||
|
/** Presentation metadata has no graph authority and cannot name arbitrary module URLs. */
|
||||||
|
export function validatePresentations(workspace: WorkspaceRevision): CapabilityValidationIssue[] {
|
||||||
|
const issues: CapabilityValidationIssue[] = [];
|
||||||
|
const fail = (path: string, message: string) => issues.push({ code: "invalid-presentation", path, message });
|
||||||
|
const interfaces = new Map(workspace.interfaceImports.map((entry) => [entry.revisionId, entry]));
|
||||||
|
const packages = new Map(workspace.packageImports.map((entry) => [entry.revisionId, entry]));
|
||||||
|
const text = (value: string, path: string) => {
|
||||||
|
if (!value.trim()) fail(path, "Presentation identities and names must be nonempty");
|
||||||
|
};
|
||||||
|
const subject = (id: string, path: string) => {
|
||||||
|
const contract = interfaces.get(id as WorkspaceRevision["interfaceImports"][number]["revisionId"]);
|
||||||
|
if (!contract || contract.template) fail(path, `Expected an imported closed subject interface: ${id}`);
|
||||||
|
};
|
||||||
|
for (const [index, pkg] of workspace.packageImports.entries()) {
|
||||||
|
for (const [alias, id] of Object.entries(pkg.clientInterfaces ?? {})) {
|
||||||
|
text(alias, `packageImports[${index}].clientInterfaces`);
|
||||||
|
subject(id, `packageImports[${index}].clientInterfaces.${alias}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ids = new Set<string>(),
|
||||||
|
names = new Set<string>();
|
||||||
|
for (const [index, presentation] of (workspace.presentations ?? []).entries()) {
|
||||||
|
const path = `presentations[${index}]`;
|
||||||
|
text(presentation.id, `${path}.id`);
|
||||||
|
text(presentation.displayName, `${path}.displayName`);
|
||||||
|
if (ids.has(presentation.id) || names.has(presentation.displayName))
|
||||||
|
fail(path, "Duplicate presentation identity or name");
|
||||||
|
ids.add(presentation.id);
|
||||||
|
names.add(presentation.displayName);
|
||||||
|
subject(presentation.subject, `${path}.subject`);
|
||||||
|
const module = packages
|
||||||
|
.get(presentation.packageRevisionId)
|
||||||
|
?.modules?.find((entry) => entry.id === presentation.componentId);
|
||||||
|
if (module) {
|
||||||
|
const contract = moduleContract(workspace.interfaceImports, module);
|
||||||
|
if (!contract || !["org.quixos.react.subject-only/1", "org.quixos.react.component/2"].includes(contract.contract))
|
||||||
|
fail(`${path}.componentId`, "Standalone presentation requires a supported React presentation contract");
|
||||||
|
if (contract?.subject?.kind !== "interface" || contract.subject.interfaceRevisionId !== presentation.subject)
|
||||||
|
fail(`${path}.subject`, "Component requires its exact declared subject interface");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fail(`${path}.componentId`, "Presentation names no installed package component");
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
@@ -65,9 +65,11 @@ export type ValueType =
|
|||||||
| { kind: "scalar"; name: ScalarValueTypeName }
|
| { kind: "scalar"; name: ScalarValueTypeName }
|
||||||
| { kind: "message"; descriptorId: string }
|
| { kind: "message"; descriptorId: string }
|
||||||
| { kind: "record"; fields: Record<string, ValueType> }
|
| { kind: "record"; fields: Record<string, ValueType> }
|
||||||
|
| { kind: "variant"; id: string; cases: Record<string, { id: string; value: ValueType }> }
|
||||||
| { kind: "object-ref"; expectation: ObjectExpectation }
|
| { kind: "object-ref"; expectation: ObjectExpectation }
|
||||||
| { kind: "optional"; value: ValueType }
|
| { kind: "optional"; value: ValueType }
|
||||||
| { kind: "list"; value: ValueType };
|
| { kind: "list"; value: ValueType }
|
||||||
|
| { kind: "change"; value: ValueType };
|
||||||
|
|
||||||
export const valueType = {
|
export const valueType = {
|
||||||
unit: { kind: "builtin", name: "unit" } as const,
|
unit: { kind: "builtin", name: "unit" } as const,
|
||||||
@@ -104,7 +106,11 @@ export interface AtomDefinition {
|
|||||||
|
|
||||||
export type InterfaceOperationMode = "call" | "watch-start" | "watch-stop" | "subscribe" | "unsubscribe";
|
export type InterfaceOperationMode = "call" | "watch-start" | "watch-stop" | "subscribe" | "unsubscribe";
|
||||||
|
|
||||||
|
/** Omission has remote semantics; availability never strengthens an imported contract. */
|
||||||
|
export type ExecutionContract = { kind: "remote" } | { kind: "snapshot" | "transaction"; targets: string[] };
|
||||||
|
|
||||||
export interface InterfaceOperation<Type = ValueType> {
|
export interface InterfaceOperation<Type = ValueType> {
|
||||||
|
execution?: ExecutionContract;
|
||||||
id: OperationId;
|
id: OperationId;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
inputType: Type;
|
inputType: Type;
|
||||||
@@ -116,6 +122,8 @@ export interface InterfaceOperation<Type = ValueType> {
|
|||||||
eventType?: Type;
|
eventType?: Type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type QueryReadContract = { execution: "native" | "rpc-permitted" | "portable" };
|
||||||
|
|
||||||
interface InterfaceMemberBase<Type = ValueType> {
|
interface InterfaceMemberBase<Type = ValueType> {
|
||||||
id: MemberId;
|
id: MemberId;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -124,6 +132,9 @@ interface InterfaceMemberBase<Type = ValueType> {
|
|||||||
|
|
||||||
export interface ValueInterfaceMember<Type = ValueType> extends InterfaceMemberBase<Type> {
|
export interface ValueInterfaceMember<Type = ValueType> extends InterfaceMemberBase<Type> {
|
||||||
kind: "value";
|
kind: "value";
|
||||||
|
/** Every operation resolves directly to the same native state slot. */
|
||||||
|
nativeField?: true;
|
||||||
|
queryRead?: QueryReadContract;
|
||||||
valueType: Type;
|
valueType: Type;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,9 +152,11 @@ export interface RelationshipInterfaceMember<
|
|||||||
Target = EdgeEndpointConstraint,
|
Target = EdgeEndpointConstraint,
|
||||||
> extends InterfaceMemberBase<Type> {
|
> extends InterfaceMemberBase<Type> {
|
||||||
kind: "relationship";
|
kind: "relationship";
|
||||||
|
queryRead?: QueryReadContract;
|
||||||
target: Target;
|
target: Target;
|
||||||
cardinality: EdgeCardinality;
|
cardinality: EdgeCardinality;
|
||||||
ordered: boolean;
|
ordered: boolean;
|
||||||
|
keyType?: "string" | "boolean" | "int64";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A named callable capability that is not value or relationship sugar. */
|
/** A named callable capability that is not value or relationship sugar. */
|
||||||
@@ -153,10 +166,21 @@ export interface OperationInterfaceMember<Type = ValueType> extends InterfaceMem
|
|||||||
outputType: Type;
|
outputType: Type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A typed code artifact. Module bindings are neither calls nor storage fields. */
|
||||||
|
export interface ModuleInterfaceMember<
|
||||||
|
Type = ValueType,
|
||||||
|
Target = EdgeEndpointConstraint,
|
||||||
|
> extends InterfaceMemberBase<Type> {
|
||||||
|
kind: "module";
|
||||||
|
contract: string;
|
||||||
|
subject?: Target;
|
||||||
|
}
|
||||||
|
|
||||||
export type InterfaceMember<Type = ValueType, Target = EdgeEndpointConstraint> =
|
export type InterfaceMember<Type = ValueType, Target = EdgeEndpointConstraint> =
|
||||||
| ValueInterfaceMember<Type>
|
| ValueInterfaceMember<Type>
|
||||||
| RelationshipInterfaceMember<Type, Target>
|
| RelationshipInterfaceMember<Type, Target>
|
||||||
| OperationInterfaceMember<Type>;
|
| OperationInterfaceMember<Type>
|
||||||
|
| ModuleInterfaceMember<Type, Target>;
|
||||||
|
|
||||||
export interface InterfaceRevision {
|
export interface InterfaceRevision {
|
||||||
interfaceId: InterfaceId;
|
interfaceId: InterfaceId;
|
||||||
@@ -172,7 +196,19 @@ export interface InterfaceRevision {
|
|||||||
argumentRequirements?: { target: ObjectExpectation; required: InterfaceRevisionId }[];
|
argumentRequirements?: { target: ObjectExpectation; required: InterfaceRevisionId }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StoragePolicy = { kind: "optimistic-register" } | { kind: "crdt-document"; updateType: ValueType };
|
export interface JournalStorageLimits {
|
||||||
|
maxEntries: number;
|
||||||
|
maxBytes: number;
|
||||||
|
maxPublicationBytes: number;
|
||||||
|
maxProtectedSubscriptions: number;
|
||||||
|
}
|
||||||
|
export type StoragePolicy =
|
||||||
|
| { kind: "register" }
|
||||||
|
| { kind: "crdt-document"; updateType: ValueType }
|
||||||
|
| { kind: "journal"; limits: JournalStorageLimits }
|
||||||
|
| { kind: "subscription"; payload: ValueType };
|
||||||
|
export type JournalPrimitive = "publish" | "readPage" | "head";
|
||||||
|
export type SubscriptionPrimitive = "attach" | "status" | "pause" | "resume" | "retire";
|
||||||
|
|
||||||
export interface StateSlotDefinition {
|
export interface StateSlotDefinition {
|
||||||
kind: "state";
|
kind: "state";
|
||||||
@@ -205,7 +241,7 @@ export interface EdgeDefinition {
|
|||||||
|
|
||||||
export type PersistentAttachment = StateSlotDefinition | EdgeDefinition;
|
export type PersistentAttachment = StateSlotDefinition | EdgeDefinition;
|
||||||
|
|
||||||
export type StatePrimitive = "read" | "write" | "watch-start" | "watch-stop";
|
export type StatePrimitive = "read" | "write" | "edit" | "watch-start" | "watch-stop";
|
||||||
|
|
||||||
export type EdgePrimitive = "resolve" | "connect" | "disconnect" | "watch-start" | "watch-stop";
|
export type EdgePrimitive = "resolve" | "connect" | "disconnect" | "watch-start" | "watch-stop";
|
||||||
|
|
||||||
@@ -218,6 +254,8 @@ export type PackageReceiverRequirement =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type DependencyPortRequirement =
|
export type DependencyPortRequirement =
|
||||||
|
| { kind: "reflection"; selectors: string[] }
|
||||||
|
| { kind: "query"; packageRevisionId: PackageRevisionId; queryId: string }
|
||||||
| {
|
| {
|
||||||
kind: "state";
|
kind: "state";
|
||||||
valueType: ValueType;
|
valueType: ValueType;
|
||||||
@@ -227,12 +265,17 @@ export type DependencyPortRequirement =
|
|||||||
kind: "edge";
|
kind: "edge";
|
||||||
target: EdgeEndpointConstraint;
|
target: EdgeEndpointConstraint;
|
||||||
cardinality: EdgeCardinality;
|
cardinality: EdgeCardinality;
|
||||||
|
keyType?: "string" | "boolean" | "int64";
|
||||||
primitives: EdgePrimitive[];
|
primitives: EdgePrimitive[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: "interface";
|
kind: "interface";
|
||||||
interfaceRevisionId: InterfaceRevisionId;
|
interfaceRevisionId: InterfaceRevisionId;
|
||||||
|
/** Exact public operation identities, not implementation export names. */
|
||||||
|
operations?: OperationId[];
|
||||||
|
receiver?: "input";
|
||||||
}
|
}
|
||||||
|
| { kind: "creation"; atomId: AtomId; inputType: ValueType }
|
||||||
| { kind: "constructor"; atomId: AtomId; inputType?: ValueType };
|
| { kind: "constructor"; atomId: AtomId; inputType?: ValueType };
|
||||||
|
|
||||||
export interface DependencyPort {
|
export interface DependencyPort {
|
||||||
@@ -242,6 +285,11 @@ export interface DependencyPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PackageExportBase {
|
interface PackageExportBase {
|
||||||
|
/** Returns this declared birth, with unchanged operation input as its initializer. */
|
||||||
|
preparation?: { port: DependencyPortId; key: string };
|
||||||
|
/** Required authoritative snapshot export in this package. */
|
||||||
|
acceptanceExportId?: PackageExportId;
|
||||||
|
execution?: ExecutionContract;
|
||||||
id: PackageExportId;
|
id: PackageExportId;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
inputType: ValueType;
|
inputType: ValueType;
|
||||||
@@ -269,7 +317,41 @@ export interface PackageConstructorExport extends PackageExportBase {
|
|||||||
|
|
||||||
export type PackageExport = PackageOperationExport | PackageFunctionExport | PackageConstructorExport;
|
export type PackageExport = PackageOperationExport | PackageFunctionExport | PackageConstructorExport;
|
||||||
|
|
||||||
|
export interface PackageModule {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
interfaceRevisionId: InterfaceRevisionId;
|
||||||
|
memberId: MemberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleBinding {
|
||||||
|
memberId: MemberId;
|
||||||
|
packageRevisionId: PackageRevisionId;
|
||||||
|
moduleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspacePresentation {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
subject: InterfaceRevisionId;
|
||||||
|
packageRevisionId: PackageRevisionId;
|
||||||
|
componentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PackageRevision {
|
export interface PackageRevision {
|
||||||
|
/** Explicit build consumers own these artifacts. Generic checking and query
|
||||||
|
* execution attach no semantics to their keys or contents. */
|
||||||
|
buildArtifacts?: Record<string, unknown>;
|
||||||
|
modules?: PackageModule[];
|
||||||
|
/** Closed interface imports exposed to this package's checked component bindings. */
|
||||||
|
clientInterfaces?: Record<string, InterfaceRevisionId>;
|
||||||
|
/** Explicit package imports supplying module selections, never the whole workspace. */
|
||||||
|
clientPackages?: Record<string, PackageRevisionId>;
|
||||||
|
controllers?: PackageController[];
|
||||||
|
queries?: import("../query/types.js").QueryDeclaration[];
|
||||||
|
checkedQueries?: import("../query/types.js").CheckedQuery[];
|
||||||
|
queryCompositions?: import("../query/composition.js").CheckedQueryComposition[];
|
||||||
|
queryTemplates?: import("../query/types.js").QueryTemplate[];
|
||||||
genericExports?: import("./generic-packages.js").GenericPackageExport[];
|
genericExports?: import("./generic-packages.js").GenericPackageExport[];
|
||||||
argumentRequirements?: { target: ObjectExpectation; required: InterfaceRevisionId }[];
|
argumentRequirements?: { target: ObjectExpectation; required: InterfaceRevisionId }[];
|
||||||
migrationCatalog?: import("./migrations.js").MigrationCatalog;
|
migrationCatalog?: import("./migrations.js").MigrationCatalog;
|
||||||
@@ -282,7 +364,26 @@ export interface PackageRevision {
|
|||||||
semanticMajor?: number;
|
semanticMajor?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Checked adapter registration. Implementations remain ordinary object capabilities. */
|
||||||
|
export interface PackageController {
|
||||||
|
/** Optional opt-in discovery: a bounded checked workspace query in this package. */
|
||||||
|
registry?: string;
|
||||||
|
id: string;
|
||||||
|
subject: InterfaceRevisionId;
|
||||||
|
snapshot: OperationId;
|
||||||
|
remote: OperationId;
|
||||||
|
input: "unit" | "value";
|
||||||
|
}
|
||||||
|
|
||||||
export type DependencyBinding =
|
export type DependencyBinding =
|
||||||
|
| { kind: "reflection"; selectors: string[]; via?: never }
|
||||||
|
| {
|
||||||
|
kind: "query";
|
||||||
|
packageRevisionId: PackageRevisionId;
|
||||||
|
queryId: string;
|
||||||
|
scope: "object" | "workspace";
|
||||||
|
via?: EdgeTraversal;
|
||||||
|
}
|
||||||
| { kind: "state"; slotId: SlotId; via?: EdgeTraversal }
|
| { kind: "state"; slotId: SlotId; via?: EdgeTraversal }
|
||||||
| {
|
| {
|
||||||
kind: "edge";
|
kind: "edge";
|
||||||
@@ -294,6 +395,13 @@ export type DependencyBinding =
|
|||||||
kind: "interface";
|
kind: "interface";
|
||||||
interfaceRevisionId: InterfaceRevisionId;
|
interfaceRevisionId: InterfaceRevisionId;
|
||||||
via?: EdgeTraversal;
|
via?: EdgeTraversal;
|
||||||
|
receiver?: "input";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "creation";
|
||||||
|
atomId: AtomId;
|
||||||
|
owners: { interfaceRevisionId: InterfaceRevisionId; inputPath: string[] }[];
|
||||||
|
via?: never;
|
||||||
}
|
}
|
||||||
| { kind: "constructor"; atomId: AtomId };
|
| { kind: "constructor"; atomId: AtomId };
|
||||||
|
|
||||||
@@ -321,6 +429,14 @@ export interface RelationshipMaterialization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Binding =
|
export type Binding =
|
||||||
|
| { kind: "journal"; slotId: SlotId; primitive: JournalPrimitive }
|
||||||
|
| { kind: "subscription"; slotId: SlotId; primitive: SubscriptionPrimitive }
|
||||||
|
| { kind: "lifecycle"; primitive: "delete" }
|
||||||
|
| {
|
||||||
|
kind: "interface";
|
||||||
|
interfaceRevisionId: InterfaceRevisionId;
|
||||||
|
operationId: OperationId;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
kind: "state";
|
kind: "state";
|
||||||
slotId: SlotId;
|
slotId: SlotId;
|
||||||
@@ -337,14 +453,26 @@ export type Binding =
|
|||||||
packageRevisionId: PackageRevisionId;
|
packageRevisionId: PackageRevisionId;
|
||||||
exportId: PackageExportId;
|
exportId: PackageExportId;
|
||||||
dependencies: BoundDependency[];
|
dependencies: BoundDependency[];
|
||||||
|
acceptanceDependencies?: BoundDependency[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface OperationBinding {
|
export interface OperationBinding {
|
||||||
operationId: OperationId;
|
operationId: OperationId;
|
||||||
binding: Binding;
|
binding: Binding;
|
||||||
|
/** Required for package-backed reads of an RPC-permitted queryable field. */
|
||||||
|
queryReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitializationField {
|
||||||
|
input: string;
|
||||||
|
binding: { kind: "state"; slotId: SlotId } | { kind: "edge"; edgeTypeId: EdgeTypeId; projectionId: EdgeProjectionId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Conformance {
|
export interface Conformance {
|
||||||
|
/** Source provenance of a compiler-expanded rule, never runtime dispatch input. */
|
||||||
|
ruleOrigin?: { ruleId: string; sourceInterfaceRevisionId: InterfaceRevisionId; line: number; column: number };
|
||||||
|
moduleBindings?: ModuleBinding[];
|
||||||
|
initialization?: InitializationField[];
|
||||||
/** Absent only for legacy assemblies awaiting explicit ownership enrollment. */
|
/** Absent only for legacy assemblies awaiting explicit ownership enrollment. */
|
||||||
id?: ConformanceId;
|
id?: ConformanceId;
|
||||||
semanticMajor?: number;
|
semanticMajor?: number;
|
||||||
@@ -363,6 +491,8 @@ export interface AtomConstructorBinding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceRevision {
|
export interface WorkspaceRevision {
|
||||||
|
presentations?: WorkspacePresentation[];
|
||||||
|
linkedQueries?: import("../query/link.js").LinkedQuery[];
|
||||||
id: WorkspaceRevisionId;
|
id: WorkspaceRevisionId;
|
||||||
workspaceId: WorkspaceId;
|
workspaceId: WorkspaceId;
|
||||||
parentRevisionIds: WorkspaceRevisionId[];
|
parentRevisionIds: WorkspaceRevisionId[];
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
|||||||
|
# Closed execution plans and artifacts
|
||||||
|
|
||||||
|
This page documents current compiler/runtime representations. Its universal
|
||||||
|
artifact inventory, React metadata and overlapping world/installation structures
|
||||||
|
are [known architectural gaps](../../../docs/ARCHITECTURE_STATUS.md), not the
|
||||||
|
[core package contract](../../../docs/ARCHITECTURE.md). Historical LF task numbers
|
||||||
|
refer to the original implementation sequence, not an active queue.
|
||||||
|
|
||||||
|
`compileExecutionPlan` validates a workspace, links its checked queries, resolves
|
||||||
|
public forwarding, and emits one versioned plan with an SHA-256 digest. It retains
|
||||||
|
exact interface/package revisions and generic applications, owner initialization,
|
||||||
|
storage definitions/profiles, selected operation identities, query placement, and
|
||||||
|
source provenance. `quixos-workspace-compile --execution-plan-out PATH` writes it.
|
||||||
|
|
||||||
|
The compiler emits operation dependencies as a graph. A traversed interface port
|
||||||
|
retains its edge/projection and exact selected operations; native reads identify
|
||||||
|
the canonical storage inputs. Cycles do not become infinitely expanded paths.
|
||||||
|
Execution and admission must apply their runtime depth/work budgets when walking
|
||||||
|
this graph. The plan includes the full linked GraphQL selections, not just root
|
||||||
|
traversals or a list of fields.
|
||||||
|
|
||||||
|
Local and replica queries must have the same root contract. A local query cannot
|
||||||
|
introduce a relationship path absent from its replica source; authors expand the
|
||||||
|
source selection explicitly. Portable getters may require extra canonical fields
|
||||||
|
and exactly-one dependency traversals, recorded in the dependency graph. Admission
|
||||||
|
resolves those inputs on the server for selected objects. This never changes the
|
||||||
|
source query's candidate membership, and does not claim a limited source is a
|
||||||
|
complete interface extent. An incomplete runtime projection remains a coverage
|
||||||
|
state, not permission to invent a missing value.
|
||||||
|
|
||||||
|
A portable transaction cannot depend on a local presentation query, including
|
||||||
|
through a selector. Pass explicit references and preconditions as mutation input.
|
||||||
|
Otherwise an admitted top-N list could select different mutation targets locally
|
||||||
|
and at acceptance. Remote transactions can implement explicit server workflows.
|
||||||
|
This restriction does not ban local queries from snapshot selectors. Such query
|
||||||
|
ports are view-relative: both hosts must use the frame's admitted candidate
|
||||||
|
universe. An executor must report unavailable coverage when that view is absent,
|
||||||
|
not substitute a full-server extent. Query-port acquisition remains unavailable
|
||||||
|
until the query executor implements this rule; emitting its checked dependency
|
||||||
|
graph is not runtime support.
|
||||||
|
|
||||||
|
## Artifacts and world identity
|
||||||
|
|
||||||
|
`PackageArtifacts` describes one package output containing a process service and
|
||||||
|
optional target modules. Implementation IDs are stable manifest keys, separate
|
||||||
|
from export capability IDs and JavaScript registry names. Each implementation
|
||||||
|
covers its ABI, export map, entrypoint, and declared asset paths/digests/sizes/MIME.
|
||||||
|
Its digest excludes its own digest field. Paths must be normalized and relative.
|
||||||
|
|
||||||
|
`checkExecutionWorld` verifies plan identity, supported codecs, exact source and
|
||||||
|
binding schema, service fallback, promised targets, declared exports, and all
|
||||||
|
supplied file bytes. The reader must confine paths/symlinks to the checked output
|
||||||
|
and honor its byte limit before allocation. `artifactFileReader` supplies that
|
||||||
|
adapter for trusted immutable output roots. `loadArtifactWorld` checks candidate
|
||||||
|
outputs without importing package code. `BrowserArtifactGateway` registers only
|
||||||
|
declared browser assets and verifies bytes before serving; Web Studio places it
|
||||||
|
behind workspace authentication. LF-05 still checks actual module
|
||||||
|
registries/imports and verifies build receipts; a manifest
|
||||||
|
claim alone does not establish the JavaScript exports or an isolated process.
|
||||||
|
|
||||||
|
The returned `camino-world-v1` descriptor includes every declared implementation
|
||||||
|
and its digest, so executor choice does not change semantics or silently load new
|
||||||
|
code. Each binding key is the JSON pair `[packageRevisionId, implementationId]`;
|
||||||
|
there can be both a service and module for the server target. World hashing uses
|
||||||
|
the core codec's canonical ordering and domain-separated SHA-256 framing. The
|
||||||
|
monorepo `camino-execution-world` check compares the compiler and core implementations.
|
||||||
|
No machine-local Nix path enters the world descriptor.
|
||||||
|
|
||||||
|
Reusable package compilation now receives a canonical per-package build contract;
|
||||||
|
exact source provenance and workspace linking remain separate. See
|
||||||
|
[package build contracts](../../../docs/PACKAGE_BUILD_CONTRACTS.md). Generation,
|
||||||
|
receipts and checking must agree on that contract; unchanged source alone does
|
||||||
|
not justify reusing an incompatible binding schema.
|
||||||
|
|
||||||
|
## Transaction-port ABI
|
||||||
|
|
||||||
|
`quixos-transaction-ports-v1` uses coordinator-issued frame/invocation handles and
|
||||||
|
monotonic invocation-local sequence numbers. A package receives its export ID,
|
||||||
|
input, and optional receiver. Port calls name a declared port and its allowed
|
||||||
|
primitive or exact interface operation; they never select arbitrary private slots,
|
||||||
|
packages, or coordinator transactions. Creation uses an explicit stable call key. Interface calls into transaction
|
||||||
|
operations also require a stable key, checked after resolving the selected
|
||||||
|
operation. The coordinator extends the birth path; it never derives that path
|
||||||
|
from an asynchronous completion order or the transport sequence number.
|
||||||
|
|
||||||
|
The coordinator resolves each port through the installed operation binding,
|
||||||
|
checks effects/selected methods, validates typed arguments/references, and joins
|
||||||
|
nested calls to the outer logical transaction. A nested failure poisons the outer domain
|
||||||
|
transaction even if application code catches it; frame cleanup cannot turn it into
|
||||||
|
a partial success. Process completion cannot independently commit. The coordinator
|
||||||
|
owns savepoints, cancellation, frame expiry, and rejection. These enforcement
|
||||||
|
mechanisms are LF-06/LF-08, not guarantees provided by TypeScript frame types.
|
||||||
|
|
||||||
|
The service control vocabulary includes readiness, cancellation, and shutdown;
|
||||||
|
transport framing and generated adapters are LF-05/LF-08. Portable modules and
|
||||||
|
services implement the same declared operation contracts. ABI compatibility is
|
||||||
|
not a statement about package trust, co-location permission, or multi-user isolation.
|
||||||
|
|
||||||
|
Portable package bindings are generated with `quixos-codegen-ts` options
|
||||||
|
`{"portable":{"target":"browser"}}` (or `server`). They expose `Contexts`,
|
||||||
|
`PortableImplementation`, checked codec declarations, and `createRegistry`.
|
||||||
|
Handlers import the shared replica core, never Replicache. Stable mutation keys
|
||||||
|
are mandatory for creation and selected nested transaction calls. Snapshot
|
||||||
|
contexts expose only their declared read methods; runtime frame checks remain
|
||||||
|
mandatory even when the TypeScript source passed compilation.
|
||||||
|
|
||||||
|
The maintained reader source in `tools/local-first/reader/portable.ts` typechecks
|
||||||
|
against both generated targets without casts. The root
|
||||||
|
`camino-portable-bindings` qualification compares Node, Window, Worker and
|
||||||
|
serialized-port behavior. Its simulated channel tests adapters, not installed
|
||||||
|
operation acquisition or process isolation; those require the coordinator.
|
||||||
|
|
||||||
|
Closed generic package applications use the same portable adapters. The checked
|
||||||
|
workspace specializes their input/output types and dependency ports before code
|
||||||
|
generation. Each application has its own export ID and generated handler name
|
||||||
|
(`name$<specialization digest>`); handlers are concrete, so a string application
|
||||||
|
and a list-of-int64 application cannot accidentally share an untyped codec.
|
||||||
|
Common implementation helpers can still be ordinary generic TypeScript functions.
|
||||||
|
This does not implement universal runtime type application or legacy constructors.
|
||||||
|
The `camino-portable-bindings` check compiles positive/negative typed consumers and
|
||||||
|
executes both specializations in Node, Chromium Window, and Worker hosts.
|
||||||
|
|
||||||
|
Use `selectArtifactImplementation(checkedManifest, selection)` to choose an
|
||||||
|
implementation by target, ABI, required export IDs, and optionally its exact ID.
|
||||||
|
Without an ID, exactly one candidate must match. Missing exports, wrong targets,
|
||||||
|
and ambiguous choices fail; manifest order never determines execution. This
|
||||||
|
selects already checked bytes and does not grant permission to run untrusted
|
||||||
|
package code in process. Multiple selected implementations remain part of the
|
||||||
|
same checked world's complete artifact inventory.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const implementation = selectArtifactImplementation(manifest, {
|
||||||
|
target: "server",
|
||||||
|
compatibility: "quixos-service-v1",
|
||||||
|
id: "isolated-service",
|
||||||
|
exports: requiredExports,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumers use the selected entrypoint, registry export and export coverage from
|
||||||
|
that manifest. The reader's Nix builder accepts per-package
|
||||||
|
`implementationSelection = { "package:reader-runtime@1" = {
|
||||||
|
browser = "browser-module"; server = "server-module"; service = "service";
|
||||||
|
}; };` overrides. Its fixture requires one implementation per lane covering that
|
||||||
|
lane's needed exports; the selector itself permits a smaller requested subset.
|
||||||
|
The installation records the chosen service and module entries. Process lifecycle
|
||||||
|
and trusted-module placement still belong to the existing runner.
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import {
|
||||||
|
executionPlanDigestAsync,
|
||||||
|
type ExecutionPlan,
|
||||||
|
type PlanPackage,
|
||||||
|
ExecutionPlanError,
|
||||||
|
hexDigest,
|
||||||
|
executionVersions,
|
||||||
|
} from "./plan.js";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { canonicalJson } from "../capability-model/evolution.js";
|
||||||
|
|
||||||
|
export interface ArtifactFile {
|
||||||
|
path: string;
|
||||||
|
digest: string;
|
||||||
|
bytes: number;
|
||||||
|
mediaType: string;
|
||||||
|
}
|
||||||
|
export interface ArtifactImplementation {
|
||||||
|
id: string;
|
||||||
|
target: string;
|
||||||
|
compatibility: "quixos-service-v1" | "quixos-js-module-v1" | "quixos-restricted-module-v1";
|
||||||
|
path: string;
|
||||||
|
ports: "quixos-transaction-ports-v1";
|
||||||
|
registryExport?: string;
|
||||||
|
exports: string[];
|
||||||
|
/** Every executable/imported local asset, including the entrypoint, must be declared. */
|
||||||
|
files: ArtifactFile[];
|
||||||
|
contentDigest: string;
|
||||||
|
}
|
||||||
|
export interface ArtifactGroup {
|
||||||
|
schemaVersion: 1;
|
||||||
|
packageRevisionId: string;
|
||||||
|
bindingDigest: string;
|
||||||
|
versions: ExecutionPlan["definition"]["versions"];
|
||||||
|
implementations: ArtifactImplementation[];
|
||||||
|
components?: ComponentArtifact[];
|
||||||
|
}
|
||||||
|
/** Installation evidence binds a reusable group to the selected exact source.
|
||||||
|
* Compilation receipts never claim that a cache hit was built at a new pin. */
|
||||||
|
export interface PackageArtifacts extends ArtifactGroup {
|
||||||
|
sourceDigest: string;
|
||||||
|
}
|
||||||
|
export const componentSharedModules = [
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react-dom/client",
|
||||||
|
"react/jsx-runtime",
|
||||||
|
"@quixos/camino-react",
|
||||||
|
"@quixos/camino-react/ui",
|
||||||
|
] as const;
|
||||||
|
export interface ComponentArtifact {
|
||||||
|
id: string;
|
||||||
|
subject: string;
|
||||||
|
compatibility: "org.quixos.react.esm/1";
|
||||||
|
path: string;
|
||||||
|
exportName: string;
|
||||||
|
sharedModules: Record<(typeof componentSharedModules)[number], string>;
|
||||||
|
files: ArtifactFile[];
|
||||||
|
contentDigest: string;
|
||||||
|
}
|
||||||
|
export interface ImplementationSelection {
|
||||||
|
target: string;
|
||||||
|
compatibility: ArtifactImplementation["compatibility"];
|
||||||
|
/** Optional only when exactly one compatible implementation covers these exports. */
|
||||||
|
id?: string;
|
||||||
|
exports: readonly string[];
|
||||||
|
}
|
||||||
|
/** Select from a checked manifest; selection does not establish package trust or isolation.
|
||||||
|
* Never resolve ambiguity by manifest order or silently substitute a requested implementation.
|
||||||
|
*/
|
||||||
|
export function selectArtifactImplementation(
|
||||||
|
manifest: PackageArtifacts,
|
||||||
|
selection: ImplementationSelection,
|
||||||
|
): ArtifactImplementation {
|
||||||
|
const candidates = manifest.implementations.filter(
|
||||||
|
(entry) =>
|
||||||
|
(selection.id === undefined || entry.id === selection.id) &&
|
||||||
|
entry.target === selection.target &&
|
||||||
|
entry.compatibility === selection.compatibility &&
|
||||||
|
selection.exports.every((id) => entry.exports.includes(id)),
|
||||||
|
);
|
||||||
|
if (candidates.length !== 1)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
candidates.length ? "AMBIGUOUS_IMPLEMENTATION" : "IMPLEMENTATION_UNAVAILABLE",
|
||||||
|
manifest.packageRevisionId,
|
||||||
|
`${selection.id ?? "Select an explicit implementation ID"}: expected one ${selection.target}/${selection.compatibility} implementation covering the requested exports; found ${candidates.length}`,
|
||||||
|
);
|
||||||
|
return candidates[0]!;
|
||||||
|
}
|
||||||
|
export interface CheckedWorld {
|
||||||
|
codec: "camino-world-v1";
|
||||||
|
plan: string;
|
||||||
|
artifacts: { binding: string; target: string; digest: string }[];
|
||||||
|
versions: Record<string, string>;
|
||||||
|
}
|
||||||
|
const fail = (path: string, message: string): never => {
|
||||||
|
throw new ExecutionPlanError("INVALID_ARTIFACT", path, message);
|
||||||
|
};
|
||||||
|
const object = (value: unknown, path: string): Record<string, unknown> => {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype)
|
||||||
|
fail(path, "Expected a plain JSON record");
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const keys = (record: Record<string, unknown>, required: string[], optional: string[], path: string) => {
|
||||||
|
if (
|
||||||
|
required.some((key) => !Object.hasOwn(record, key)) ||
|
||||||
|
Object.keys(record).some((key) => ![...required, ...optional].includes(key))
|
||||||
|
)
|
||||||
|
fail(path, "Unexpected or missing fields");
|
||||||
|
};
|
||||||
|
const text = (value: unknown, path: string): string => {
|
||||||
|
if (
|
||||||
|
typeof value !== "string" ||
|
||||||
|
!value.length ||
|
||||||
|
value.length > 4096 ||
|
||||||
|
value.includes("\0") ||
|
||||||
|
Buffer.from(value, "utf8").toString("utf8") !== value
|
||||||
|
)
|
||||||
|
fail(path, "Expected bounded nonempty text");
|
||||||
|
return value as string;
|
||||||
|
};
|
||||||
|
const digest = (value: unknown, path: string): string => {
|
||||||
|
if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) fail(path, "Expected SHA-256 hex");
|
||||||
|
return value as string;
|
||||||
|
};
|
||||||
|
const array = (value: unknown, path: string): unknown[] => {
|
||||||
|
if (!Array.isArray(value) || value.length > 10000) fail(path, "Expected a bounded array");
|
||||||
|
return value as unknown[];
|
||||||
|
};
|
||||||
|
const unique = (values: string[], path: string) => {
|
||||||
|
if (new Set(values).size !== values.length) fail(path, "Duplicate identities");
|
||||||
|
};
|
||||||
|
export function artifactPath(value: unknown): string {
|
||||||
|
const path = text(value, "artifact path");
|
||||||
|
if (
|
||||||
|
path.startsWith("/") ||
|
||||||
|
path.includes("\\") ||
|
||||||
|
path.split("/").some((part) => !part || part === "." || part === "..") ||
|
||||||
|
/[\u0000-\u001f\u007f?#%]/.test(path)
|
||||||
|
)
|
||||||
|
fail(path, "Expected a normalized relative artifact path");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
/** No self-reference: the digest covers ABI/export metadata and the digests of every declared file. */
|
||||||
|
export function implementationDigest(implementation: Omit<ArtifactImplementation, "contentDigest">): string {
|
||||||
|
return hexDigest({
|
||||||
|
...implementation,
|
||||||
|
exports: [...implementation.exports].sort(),
|
||||||
|
files: [...implementation.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function componentArtifactDigest(component: Omit<ComponentArtifact, "contentDigest">): string {
|
||||||
|
return hexDigest({
|
||||||
|
...component,
|
||||||
|
files: [...component.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function parseArtifactFiles(value: unknown, path: string): ArtifactFile[] {
|
||||||
|
const files = array(value, path).map((value): ArtifactFile => {
|
||||||
|
const file = object(value, path);
|
||||||
|
keys(file, ["path", "digest", "bytes", "mediaType"], [], path);
|
||||||
|
if (!Number.isSafeInteger(file.bytes) || (file.bytes as number) < 0 || (file.bytes as number) > 256 * 1024 * 1024)
|
||||||
|
fail(path, "Artifact file exceeds size bound");
|
||||||
|
return {
|
||||||
|
path: artifactPath(file.path),
|
||||||
|
digest: digest(file.digest, path),
|
||||||
|
bytes: file.bytes as number,
|
||||||
|
mediaType: text(file.mediaType, path),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
unique(
|
||||||
|
files.map((file) => file.path),
|
||||||
|
path,
|
||||||
|
);
|
||||||
|
return files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
||||||
|
}
|
||||||
|
function parseComponent(value: unknown, index: number): ComponentArtifact {
|
||||||
|
const path = `components[${index}]`,
|
||||||
|
entry = object(value, path);
|
||||||
|
keys(
|
||||||
|
entry,
|
||||||
|
["id", "subject", "compatibility", "path", "exportName", "sharedModules", "files", "contentDigest"],
|
||||||
|
[],
|
||||||
|
path,
|
||||||
|
);
|
||||||
|
if (entry.compatibility !== "org.quixos.react.esm/1") fail(path, "Unsupported component ABI");
|
||||||
|
const files = parseArtifactFiles(entry.files, `${path}.files`);
|
||||||
|
const shared = object(entry.sharedModules, `${path}.sharedModules`);
|
||||||
|
keys(shared, [...componentSharedModules], [], `${path}.sharedModules`);
|
||||||
|
const sharedModules = Object.fromEntries(
|
||||||
|
componentSharedModules.map((name) => [name, artifactPath(shared[name])]),
|
||||||
|
) as ComponentArtifact["sharedModules"];
|
||||||
|
const entryPath = artifactPath(entry.path),
|
||||||
|
exportName = text(entry.exportName, path);
|
||||||
|
if (!/^[$A-Z_a-z][$\w]*$/.test(exportName)) fail(path, "Expected a JavaScript component export name");
|
||||||
|
for (const modulePath of [entryPath, ...Object.values(sharedModules)])
|
||||||
|
if (!files.some((file) => file.path === modulePath && file.mediaType === "text/javascript"))
|
||||||
|
fail(path, "Component entry and shared modules require declared JavaScript files");
|
||||||
|
const component: ComponentArtifact = {
|
||||||
|
id: text(entry.id, path),
|
||||||
|
subject: text(entry.subject, path),
|
||||||
|
compatibility: "org.quixos.react.esm/1",
|
||||||
|
path: entryPath,
|
||||||
|
exportName,
|
||||||
|
sharedModules,
|
||||||
|
files,
|
||||||
|
contentDigest: digest(entry.contentDigest, path),
|
||||||
|
};
|
||||||
|
const { contentDigest, ...body } = component;
|
||||||
|
if (componentArtifactDigest(body) !== contentDigest) fail(path, "Component metadata digest mismatch");
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
export function parseArtifactGroup(value: unknown): ArtifactGroup {
|
||||||
|
const record = object(value, "manifest");
|
||||||
|
keys(
|
||||||
|
record,
|
||||||
|
["schemaVersion", "packageRevisionId", "bindingDigest", "versions", "implementations"],
|
||||||
|
["components"],
|
||||||
|
"manifest",
|
||||||
|
);
|
||||||
|
if (record.schemaVersion !== 1) fail("manifest", "Unsupported artifact manifest version");
|
||||||
|
const versions = object(record.versions, "versions");
|
||||||
|
keys(versions, ["value", "reference", "ports", "crdt"], [], "versions");
|
||||||
|
for (const [key, value] of Object.entries(versions)) text(value, `versions.${key}`);
|
||||||
|
const implementations = array(record.implementations, "implementations").map(
|
||||||
|
(value, index): ArtifactImplementation => {
|
||||||
|
const path = `implementations[${index}]`,
|
||||||
|
entry = object(value, path);
|
||||||
|
keys(
|
||||||
|
entry,
|
||||||
|
["id", "target", "compatibility", "path", "ports", "exports", "files", "contentDigest"],
|
||||||
|
["registryExport"],
|
||||||
|
path,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!["quixos-service-v1", "quixos-js-module-v1", "quixos-restricted-module-v1"].includes(
|
||||||
|
String(entry.compatibility),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fail(path, "Unsupported artifact ABI");
|
||||||
|
if (entry.ports !== "quixos-transaction-ports-v1") fail(path, "Unsupported transaction port ABI");
|
||||||
|
if (
|
||||||
|
entry.compatibility === "quixos-service-v1" &&
|
||||||
|
(entry.target !== "server" || entry.registryExport !== undefined)
|
||||||
|
)
|
||||||
|
fail(path, "Service must target server and has no JS registry export");
|
||||||
|
if (entry.compatibility !== "quixos-service-v1") text(entry.registryExport, `${path}.registryExport`);
|
||||||
|
if (entry.compatibility === "quixos-restricted-module-v1" && entry.target !== "server")
|
||||||
|
fail(path, "Restricted modules must target server");
|
||||||
|
const exports = array(entry.exports, `${path}.exports`).map((value) => text(value, `${path}.exports`));
|
||||||
|
unique(exports, `${path}.exports`);
|
||||||
|
const files = parseArtifactFiles(entry.files, `${path}.files`);
|
||||||
|
const entryPath = artifactPath(entry.path);
|
||||||
|
if (!files.some((file) => file.path === entryPath)) fail(path, "Entrypoint is not covered by the file manifest");
|
||||||
|
const result: ArtifactImplementation = {
|
||||||
|
id: text(entry.id, path),
|
||||||
|
target: text(entry.target, path),
|
||||||
|
compatibility: entry.compatibility as ArtifactImplementation["compatibility"],
|
||||||
|
path: entryPath,
|
||||||
|
ports: "quixos-transaction-ports-v1",
|
||||||
|
exports: exports.sort(),
|
||||||
|
files: files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
|
||||||
|
contentDigest: digest(entry.contentDigest, path),
|
||||||
|
...(entry.registryExport !== undefined ? { registryExport: entry.registryExport as string } : {}),
|
||||||
|
};
|
||||||
|
const { contentDigest, ...body } = result;
|
||||||
|
if (implementationDigest(body) !== contentDigest) fail(path, "Implementation metadata digest mismatch");
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
unique(
|
||||||
|
implementations.map((implementation) => implementation.id),
|
||||||
|
"implementations",
|
||||||
|
);
|
||||||
|
const components =
|
||||||
|
record.components === undefined ? undefined : array(record.components, "components").map(parseComponent);
|
||||||
|
if (components)
|
||||||
|
unique(
|
||||||
|
components.map((component) => component.id),
|
||||||
|
"components",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
packageRevisionId: text(record.packageRevisionId, "packageRevisionId"),
|
||||||
|
bindingDigest: digest(record.bindingDigest, "bindingDigest"),
|
||||||
|
versions: versions as unknown as PackageArtifacts["versions"],
|
||||||
|
implementations,
|
||||||
|
...(components ? { components } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function parsePackageArtifacts(value: unknown): PackageArtifacts {
|
||||||
|
const { sourceDigest, ...group } = object(value, "manifest");
|
||||||
|
return { ...parseArtifactGroup(group), sourceDigest: digest(sourceDigest, "sourceDigest") };
|
||||||
|
}
|
||||||
|
export function checkPackageCoverage(
|
||||||
|
expected: Pick<PlanPackage, "revision" | "modules" | "components" | "exports" | "bindingDigest" | "sourceDigest">,
|
||||||
|
manifest: PackageArtifacts,
|
||||||
|
): void {
|
||||||
|
for (const module of expected.modules ?? [])
|
||||||
|
if (!expected.components?.some((component) => component.id === module.id))
|
||||||
|
fail(module.id, `No artifact consumer for module contract ${module.contract} and its subject`);
|
||||||
|
if (expected.bindingDigest !== manifest.bindingDigest || expected.sourceDigest !== manifest.sourceDigest)
|
||||||
|
fail(expected.revision, "Substituted binding schema or source revision");
|
||||||
|
for (const component of manifest.components ?? []) {
|
||||||
|
const declaration = expected.components?.find((entry) => entry.id === component.id);
|
||||||
|
if (!declaration || declaration.subject !== component.subject || declaration.runtime !== "react")
|
||||||
|
fail(component.id, "Component differs from its declared subject/runtime contract");
|
||||||
|
}
|
||||||
|
for (const component of expected.components ?? [])
|
||||||
|
if (!manifest.components?.some((entry) => entry.id === component.id))
|
||||||
|
fail(component.id, "Missing component artifact");
|
||||||
|
for (const implementation of manifest.implementations)
|
||||||
|
for (const id of implementation.exports) {
|
||||||
|
const entry = expected.exports.find((e) => e.id === id);
|
||||||
|
if (!entry) fail(implementation.id, `Unknown export ${id}`);
|
||||||
|
if (
|
||||||
|
implementation.compatibility === "quixos-restricted-module-v1" &&
|
||||||
|
(entry!.execution.kind === "remote" || !entry!.execution.targets.includes("server"))
|
||||||
|
)
|
||||||
|
fail(implementation.id, `Export ${id} does not promise restricted server computation`);
|
||||||
|
if (
|
||||||
|
implementation.target !== "server" &&
|
||||||
|
(!entry?.execution ||
|
||||||
|
entry.execution.kind === "remote" ||
|
||||||
|
!entry.execution.targets.includes(implementation.target))
|
||||||
|
)
|
||||||
|
fail(implementation.id, `Export ${id} does not promise target ${implementation.target}`);
|
||||||
|
}
|
||||||
|
for (const entry of expected.exports) {
|
||||||
|
if (!manifest.implementations.some((i) => i.compatibility === "quixos-service-v1" && i.exports.includes(entry.id)))
|
||||||
|
fail(expected.revision, `Missing service fallback for ${entry.id}`);
|
||||||
|
if (entry.execution.kind !== "remote")
|
||||||
|
for (const target of entry.execution.targets)
|
||||||
|
if (
|
||||||
|
!manifest.implementations.some(
|
||||||
|
(i) =>
|
||||||
|
i.target === target &&
|
||||||
|
i.exports.includes(entry.id) &&
|
||||||
|
(target === "server" || i.compatibility !== "quixos-service-v1"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fail(expected.revision, `Missing ${target} implementation for ${entry.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export type ArtifactReader = (packageRevisionId: string, path: string, maxBytes: number) => Promise<Uint8Array>;
|
||||||
|
/** Shared by standalone package checks and complete world installation. */
|
||||||
|
export async function checkArtifactFiles(manifest: ArtifactGroup, read: ArtifactReader): Promise<void> {
|
||||||
|
const files = new Map<string, ArtifactFile>();
|
||||||
|
for (const artifact of [...manifest.implementations, ...(manifest.components ?? [])])
|
||||||
|
for (const file of artifact.files) {
|
||||||
|
const existing = files.get(file.path);
|
||||||
|
if (existing && canonicalJson(existing) !== canonicalJson(file)) fail(file.path, "Conflicting file descriptors");
|
||||||
|
files.set(file.path, file);
|
||||||
|
}
|
||||||
|
for (const file of files.values()) {
|
||||||
|
const bytes = await read(manifest.packageRevisionId, file.path, file.bytes);
|
||||||
|
if (bytes.byteLength !== file.bytes || createHash("sha256").update(bytes).digest("hex") !== file.digest)
|
||||||
|
fail(file.path, "Artifact bytes do not match the checked content digest");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Caller supplies a reader confined to the resolved immutable package output, including symlink checks. */
|
||||||
|
export async function checkExecutionWorld(
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
inputs: readonly unknown[],
|
||||||
|
read: ArtifactReader,
|
||||||
|
): Promise<CheckedWorld> {
|
||||||
|
if (canonicalJson(plan.definition.versions) !== canonicalJson(executionVersions))
|
||||||
|
fail("world", "Unsupported plan codec versions");
|
||||||
|
if ((await executionPlanDigestAsync(plan)) !== plan.digest) fail("plan", "Plan digest mismatch");
|
||||||
|
const manifests = inputs.map(parsePackageArtifacts);
|
||||||
|
unique(
|
||||||
|
manifests.map((m) => m.packageRevisionId),
|
||||||
|
"packages",
|
||||||
|
);
|
||||||
|
if (manifests.length !== plan.definition.packages.length) fail("world", "Package closure differs from plan");
|
||||||
|
const artifacts: CheckedWorld["artifacts"] = [];
|
||||||
|
for (const expected of plan.definition.packages) {
|
||||||
|
const manifest =
|
||||||
|
manifests.find((m) => m.packageRevisionId === expected.revision) ??
|
||||||
|
fail(expected.revision, "Missing package artifact");
|
||||||
|
checkPackageCoverage(expected, manifest);
|
||||||
|
if (canonicalJson(manifest.versions) !== canonicalJson(plan.definition.versions))
|
||||||
|
fail(expected.revision, "Incompatible runtime codecs");
|
||||||
|
await checkArtifactFiles(manifest, read);
|
||||||
|
for (const implementation of manifest.implementations) {
|
||||||
|
artifacts.push({
|
||||||
|
binding: JSON.stringify([expected.revision, implementation.id]),
|
||||||
|
target: implementation.target,
|
||||||
|
digest: implementation.contentDigest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const component of manifest.components ?? []) {
|
||||||
|
artifacts.push({
|
||||||
|
binding: JSON.stringify([expected.revision, "component", component.id]),
|
||||||
|
target: "browser-renderer",
|
||||||
|
digest: component.contentDigest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
codec: "camino-world-v1",
|
||||||
|
plan: plan.digest,
|
||||||
|
artifacts: artifacts.sort((a, b) =>
|
||||||
|
a.binding < b.binding ? -1 : a.binding > b.binding ? 1 : a.target < b.target ? -1 : a.target > b.target ? 1 : 0,
|
||||||
|
),
|
||||||
|
versions: { ...plan.definition.versions },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** Node counterpart of replica-core worldDigest. The wire fixture checks cross-package agreement. */
|
||||||
|
export function checkedWorldDigest(world: CheckedWorld): string {
|
||||||
|
const normalized = {
|
||||||
|
...world,
|
||||||
|
artifacts: [...world.artifacts].sort((a, b) =>
|
||||||
|
a.binding < b.binding ? -1 : a.binding > b.binding ? 1 : a.target < b.target ? -1 : a.target > b.target ? 1 : 0,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const json = canonicalJson(normalized),
|
||||||
|
content = createHash("sha256").update(json).digest("hex");
|
||||||
|
const components = ["camino-world-v1", content].map((text) => Buffer.from(text, "utf8"));
|
||||||
|
const count = Buffer.alloc(4);
|
||||||
|
count.writeUInt32BE(components.length);
|
||||||
|
const pieces = [count];
|
||||||
|
for (const bytes of components) {
|
||||||
|
const length = Buffer.alloc(4);
|
||||||
|
length.writeUInt32BE(bytes.length);
|
||||||
|
pieces.push(length, bytes);
|
||||||
|
}
|
||||||
|
return createHash("sha256").update(Buffer.concat(pieces)).digest("hex");
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import type { BindingSchema, BindingContractSchema } from "../bindings/index.js";
|
||||||
|
import type { InterfaceRevision, PackageRevision } from "../capability-model/types.js";
|
||||||
|
import { canonicalJson } from "../capability-model/evolution.js";
|
||||||
|
import { moduleContract } from "../capability-model/modules.js";
|
||||||
|
import { generatePackageComponentBindings, publicQueryContract, type PublicQueryContract } from "./client-contracts.js";
|
||||||
|
import { moduleTypeContext } from "./module-contracts.js";
|
||||||
|
import { executionVersions, hexDigest, ExecutionPlanError, type PlanPackage } from "./plan.js";
|
||||||
|
import { preparationArtifact, type ReactPreparationArtifact } from "../react-build/declarations.js";
|
||||||
|
|
||||||
|
/** Inputs to compilation, independent of source pins and installed providers.
|
||||||
|
* Declared ports select interface contracts; the React consumer projects its
|
||||||
|
* imported interfaces, public queries and module inputs. Ordinary code
|
||||||
|
* and richer language type companions remain separate, explicit Nix inputs. */
|
||||||
|
export interface PackageBuildContract {
|
||||||
|
definition: {
|
||||||
|
codec: "camino-package-build-v1";
|
||||||
|
package: Omit<PlanPackage, "source" | "sourceDigest" | "bindingDigest">;
|
||||||
|
versions: typeof executionVersions;
|
||||||
|
bindings: BindingContractSchema;
|
||||||
|
queries: PublicQueryContract[];
|
||||||
|
component?: string;
|
||||||
|
moduleInterfaces: { revisionId: string; definitionId?: string; members: string[] }[];
|
||||||
|
diagnostics: string[];
|
||||||
|
};
|
||||||
|
digest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalize = <T>(value: T): T => JSON.parse(canonicalJson(value)) as T;
|
||||||
|
const indexSchema = (schema: BindingSchema) => ({
|
||||||
|
interfaces: new Map<string, InterfaceRevision>(
|
||||||
|
[...schema.interfaces, ...(schema.interfaceTemplates ?? [])].map((entry) => [entry.revisionId, entry]),
|
||||||
|
),
|
||||||
|
packages: new Map<string, PackageRevision>(schema.packages.map((entry) => [entry.revisionId, entry])),
|
||||||
|
});
|
||||||
|
export function packageBuildContexts(
|
||||||
|
schema: BindingSchema,
|
||||||
|
): Record<string, { contract: PackageBuildContract; sourceDigest: string }> {
|
||||||
|
const index = indexSchema(schema);
|
||||||
|
return Object.fromEntries(
|
||||||
|
schema.packages.map((pkg) => [
|
||||||
|
pkg.revisionId,
|
||||||
|
{
|
||||||
|
contract: projectPackageContract(schema, pkg.revisionId, index),
|
||||||
|
sourceDigest: hexDigest({ revision: pkg.revisionId, source: pkg.source }),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function packageBuildContract(schema: BindingSchema, revision: string): PackageBuildContract {
|
||||||
|
return projectPackageContract(schema, revision, indexSchema(schema));
|
||||||
|
}
|
||||||
|
function projectPackageContract(
|
||||||
|
schema: BindingSchema,
|
||||||
|
revision: string,
|
||||||
|
index: ReturnType<typeof indexSchema>,
|
||||||
|
): PackageBuildContract {
|
||||||
|
const declared = index.packages.get(revision);
|
||||||
|
if (!declared) throw new ExecutionPlanError("UNKNOWN_BUILD_PACKAGE", revision, "Missing package declaration");
|
||||||
|
const pkg = normalize(declared);
|
||||||
|
const interfaces = (ids: Iterable<string>) =>
|
||||||
|
[...ids].map((id) => {
|
||||||
|
const iface = index.interfaces.get(id);
|
||||||
|
if (!iface) throw new ExecutionPlanError("UNKNOWN_BUILD_INTERFACE", id, "Missing declared interface");
|
||||||
|
return normalize(iface);
|
||||||
|
});
|
||||||
|
const modules = [...(pkg.modules ?? [])]
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
.map((module) => {
|
||||||
|
const contract = moduleContract(interfaces([module.interfaceRevisionId]), module);
|
||||||
|
if (!contract) throw new ExecutionPlanError("UNKNOWN_MODULE_CONTRACT", module.id, "Missing module contract");
|
||||||
|
return {
|
||||||
|
...module,
|
||||||
|
contract: contract.contract,
|
||||||
|
...(contract.subject ? { subject: contract.subject } : {}),
|
||||||
|
...moduleTypeContext(index.interfaces.get(module.interfaceRevisionId)!),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const components = modules.flatMap((module) =>
|
||||||
|
["org.quixos.react.component/1", "org.quixos.react.component/2", "org.quixos.react.subject-only/1"].includes(
|
||||||
|
module.contract,
|
||||||
|
) && module.subject?.kind === "interface"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: module.id,
|
||||||
|
displayName: module.displayName,
|
||||||
|
subject: module.subject.interfaceRevisionId,
|
||||||
|
runtime: "react" as const,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const shape: PackageBuildContract["definition"]["package"] = {
|
||||||
|
revision,
|
||||||
|
exports: [...pkg.exports]
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
.map((entry) => ({ id: entry.id, execution: entry.execution ?? { kind: "remote" } })),
|
||||||
|
...(modules.length ? { modules } : {}),
|
||||||
|
...(components.length ? { components } : {}),
|
||||||
|
...(pkg.controllers?.length ? { controllers: [...pkg.controllers].sort((a, b) => a.id.localeCompare(b.id)) } : {}),
|
||||||
|
};
|
||||||
|
const portInterfaces = new Set(
|
||||||
|
pkg.exports.flatMap((entry) =>
|
||||||
|
entry.dependencyPorts.flatMap((port) =>
|
||||||
|
port.requirement.kind === "interface" ? [port.requirement.interfaceRevisionId] : [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const bindings: BindingContractSchema = {
|
||||||
|
format: "quixos-bindings",
|
||||||
|
version: 1,
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
revisionId: pkg.revisionId,
|
||||||
|
exports: [...pkg.exports].sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
...(pkg.genericExports ? { genericExports: pkg.genericExports } : {}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
interfaces: interfaces(portInterfaces)
|
||||||
|
.map(({ source, ...contract }) => contract)
|
||||||
|
.sort((a, b) => a.revisionId.localeCompare(b.revisionId)),
|
||||||
|
};
|
||||||
|
const preparation = pkg.buildArtifacts?.[preparationArtifact] as ReactPreparationArtifact | undefined;
|
||||||
|
const diagnostics = components.flatMap((component) => {
|
||||||
|
const input = preparation?.modules.find((entry) => entry.module === component.id);
|
||||||
|
return input && pkg.queryCompositions?.some((entry) => entry.id === input.composition)
|
||||||
|
? []
|
||||||
|
: [`${component.id}: declare checked inputs in quixos.react.json`];
|
||||||
|
});
|
||||||
|
// Only interfaces whose modules are exposed by these bindings need language
|
||||||
|
// type companions. Reference types themselves do not recursively import the
|
||||||
|
// referenced object's implementation or its conformance inventory.
|
||||||
|
const used = new Set<string>(Object.values(pkg.clientInterfaces ?? {}));
|
||||||
|
for (const module of modules) used.add(module.interfaceRevisionId);
|
||||||
|
for (const component of components) used.add(component.subject);
|
||||||
|
for (const entry of preparation?.modules ?? [])
|
||||||
|
for (const child of entry.children) if (child.role) used.add(child.role.interface);
|
||||||
|
const moduleInterfaces = interfaces(used)
|
||||||
|
.flatMap((iface: InterfaceRevision) => {
|
||||||
|
const members = iface.members
|
||||||
|
.filter((member) => member.kind === "module")
|
||||||
|
.map((member) => member.id)
|
||||||
|
.sort();
|
||||||
|
return members.length
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
revisionId: iface.revisionId,
|
||||||
|
...(iface.application ? { definitionId: iface.application.definitionId } : {}),
|
||||||
|
members,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.revisionId.localeCompare(b.revisionId));
|
||||||
|
for (const module of modules)
|
||||||
|
for (const argument of index.interfaces.get(module.interfaceRevisionId)?.application?.arguments ?? [])
|
||||||
|
if (argument.kind === "object" && argument.target.kind === "interface")
|
||||||
|
used.add(argument.target.interfaceRevisionId);
|
||||||
|
const providers = new Set<string>(Object.values(pkg.clientPackages ?? {}));
|
||||||
|
for (const query of pkg.queries ?? [])
|
||||||
|
for (const imported of query.importedFragments ?? []) providers.add(imported.packageRevisionId);
|
||||||
|
providers.delete(pkg.revisionId);
|
||||||
|
const componentSchema: BindingSchema = {
|
||||||
|
format: "quixos-bindings",
|
||||||
|
version: 1,
|
||||||
|
interfaces: interfaces(used),
|
||||||
|
packages: [
|
||||||
|
pkg,
|
||||||
|
...[...providers].map((id) => {
|
||||||
|
const provider = index.packages.get(id);
|
||||||
|
if (!provider) throw new ExecutionPlanError("UNKNOWN_BUILD_PROVIDER", id, "Missing declared package provider");
|
||||||
|
return normalize(provider);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const definition: PackageBuildContract["definition"] = {
|
||||||
|
codec: "camino-package-build-v1",
|
||||||
|
package: shape,
|
||||||
|
versions: { ...executionVersions },
|
||||||
|
bindings,
|
||||||
|
queries: [
|
||||||
|
...new Map(
|
||||||
|
pkg.exports.flatMap((entry) =>
|
||||||
|
entry.dependencyPorts.flatMap((port) => {
|
||||||
|
if (port.requirement.kind !== "query") return [];
|
||||||
|
const requirement = port.requirement;
|
||||||
|
const provider = index.packages.get(requirement.packageRevisionId);
|
||||||
|
if (!provider)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"UNKNOWN_QUERY_PROVIDER",
|
||||||
|
requirement.packageRevisionId,
|
||||||
|
"Missing declared query provider",
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
JSON.stringify([requirement.packageRevisionId, requirement.queryId]),
|
||||||
|
publicQueryContract(provider, requirement.queryId),
|
||||||
|
] as const,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).entries(),
|
||||||
|
]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([, query]) => query),
|
||||||
|
...(components.length && !diagnostics.length
|
||||||
|
? { component: generatePackageComponentBindings(componentSchema, shape) }
|
||||||
|
: {}),
|
||||||
|
moduleInterfaces,
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
const normalized = JSON.parse(canonicalJson(definition)) as typeof definition;
|
||||||
|
return { definition: normalized, digest: hexDigest(normalized) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkPackageBuildContract(contract: PackageBuildContract): void {
|
||||||
|
if (contract.definition.codec !== "camino-package-build-v1" || hexDigest(contract.definition) !== contract.digest)
|
||||||
|
throw new ExecutionPlanError("INVALID_BUILD_CONTRACT", contract.digest, "Package contract digest mismatch");
|
||||||
|
if (contract.definition.diagnostics.length)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"INCOMPLETE_BUILD_CONTRACT",
|
||||||
|
contract.definition.package.revision,
|
||||||
|
contract.definition.diagnostics.join("; "),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { graphExecutionRepresentation } from "./graph-execution.js";
|
||||||
|
import { lstat } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { hexDigest, hexDigestAsync, sealExecutionPlan, ExecutionPlanError, type ExecutionPlan } from "./plan.js";
|
||||||
|
import {
|
||||||
|
loadExecutionInstallation,
|
||||||
|
createClientContractCatalog,
|
||||||
|
type PackageInstallationSelection,
|
||||||
|
} from "./installation.js";
|
||||||
|
import {
|
||||||
|
candidateMetadataBudget,
|
||||||
|
readCandidateMetadata,
|
||||||
|
type CandidateMetadataBudget,
|
||||||
|
type CandidateMetadataScope,
|
||||||
|
} from "./metadata.js";
|
||||||
|
const rootPattern = /^\/nix\/store\/[a-z0-9]{32}-[^/\s]+$/;
|
||||||
|
async function readJson(root: string, file: string, scope: CandidateMetadataScope) {
|
||||||
|
// Query Trees add checked projections to an already large application plan.
|
||||||
|
// The Reader now exceeds 32 MiB even without indentation. This file bound
|
||||||
|
// does not replace the stricter aggregate/owner parse-allocation budgets.
|
||||||
|
const input = await readCandidateMetadata(join(root, file), scope, 64 * 1024 * 1024);
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
value: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(input.bytes)),
|
||||||
|
releaseTree: input.releaseTree,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
input.releaseTree();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
input.releaseInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Load the checked Nix output, preserving its original compilation and provenance.
|
||||||
|
* The selected toolchain validates generated code during the build. This loader
|
||||||
|
* checks stored identities and artifact/runtime contracts, never recompiles the
|
||||||
|
* historical candidate with whichever compiler happens to run the host. */
|
||||||
|
export async function loadCheckedInstallationCandidate(
|
||||||
|
root: string,
|
||||||
|
selections: Readonly<Record<string, PackageInstallationSelection>> = {},
|
||||||
|
resources: { budget?: CandidateMetadataBudget; owner?: string } = {},
|
||||||
|
) {
|
||||||
|
if (!rootPattern.test(root) || !(await lstat(root)).isDirectory())
|
||||||
|
throw Error("Candidate must be an immutable Nix output directory");
|
||||||
|
const scope = (resources.budget ?? candidateMetadataBudget).acquire(resources.owner ?? root);
|
||||||
|
try {
|
||||||
|
// Validate the duplicated source contract by digest, then discard its tree
|
||||||
|
// before allocating the substantially larger checked execution plan.
|
||||||
|
const workspaceDigest = await (async () => {
|
||||||
|
const input = await readJson(root, "candidate.json", scope);
|
||||||
|
try {
|
||||||
|
const { executionContracts: _legacyContracts, ...workspace } = input.value;
|
||||||
|
return await hexDigestAsync(workspace);
|
||||||
|
} finally {
|
||||||
|
input.releaseTree();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// Sequential reads avoid partially rejected Promise.all() work allocating
|
||||||
|
// after the failed candidate's reservation has been released.
|
||||||
|
const values = [];
|
||||||
|
for (const file of [
|
||||||
|
"execution-plan.json",
|
||||||
|
"execution-artifacts.json",
|
||||||
|
"source.json",
|
||||||
|
"entry.json",
|
||||||
|
"kernel.json",
|
||||||
|
"queries.json",
|
||||||
|
]) {
|
||||||
|
const parsed = await readJson(root, file, scope);
|
||||||
|
values.push(parsed.value);
|
||||||
|
}
|
||||||
|
const [plan, artifacts, source, entryRequest, kernel, queries] = values;
|
||||||
|
if (
|
||||||
|
plan?.definition?.codec !== "camino-execution-plan-v1" ||
|
||||||
|
!(await sealExecutionPlan(plan)) ||
|
||||||
|
workspaceDigest !== (await hexDigestAsync(plan.definition.workspace))
|
||||||
|
)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"CANDIDATE_PLAN_MISMATCH",
|
||||||
|
root,
|
||||||
|
"Stored plan must match its digest and exact workspace source contract",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
source.kind !== "workspace" ||
|
||||||
|
source.commit !== plan.definition.workspace.sourceRootCommit ||
|
||||||
|
typeof source.repository !== "string"
|
||||||
|
)
|
||||||
|
throw new ExecutionPlanError("CANDIDATE_SOURCE_MISMATCH", root, "Expected exact workspace source provenance");
|
||||||
|
const repository = new URL(source.repository);
|
||||||
|
if (
|
||||||
|
!["https:", "ssh:"].includes(repository.protocol) ||
|
||||||
|
repository.username ||
|
||||||
|
repository.password ||
|
||||||
|
repository.hash ||
|
||||||
|
repository.search
|
||||||
|
)
|
||||||
|
throw new ExecutionPlanError("CANDIDATE_SOURCE_INVALID", root, "Expected a source URL without credentials");
|
||||||
|
if (
|
||||||
|
artifacts.codec !== "camino-checked-execution-v2" ||
|
||||||
|
typeof artifacts.generator !== "string" ||
|
||||||
|
!/^[0-9a-f]{64}$/.test(artifacts.generator) ||
|
||||||
|
!artifacts.roots ||
|
||||||
|
typeof artifacts.roots !== "object" ||
|
||||||
|
Array.isArray(artifacts.roots)
|
||||||
|
)
|
||||||
|
throw new ExecutionPlanError("CANDIDATE_ARTIFACTS_INVALID", root, "Expected checked package roots");
|
||||||
|
for (const entry of Object.values(artifacts.roots))
|
||||||
|
if (typeof entry !== "string" || !rootPattern.test(entry))
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"CANDIDATE_ARTIFACTS_INVALID",
|
||||||
|
root,
|
||||||
|
"Package artifacts must be immutable Nix roots",
|
||||||
|
);
|
||||||
|
const installation = await loadExecutionInstallation(plan, artifacts.roots, selections, scope);
|
||||||
|
const entry = compileWorkspaceEntry(plan, entryRequest);
|
||||||
|
for (const [name, program, codec] of [
|
||||||
|
["kernel", kernel, "camino-kernel-program-v1"],
|
||||||
|
["queries", queries, "camino-query-program-v1"],
|
||||||
|
] as const)
|
||||||
|
if (
|
||||||
|
program?.codec !== codec ||
|
||||||
|
program.plan !== kernel.plan ||
|
||||||
|
(await hexDigestAsync(program)) !== artifacts.programs?.[name]
|
||||||
|
)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"CANDIDATE_PROGRAM_MISMATCH",
|
||||||
|
name,
|
||||||
|
"Stored interpreter input must match the checked build and plan",
|
||||||
|
);
|
||||||
|
const expectedGraph = graphExecutionRepresentation(plan, installation, kernel, queries);
|
||||||
|
// The stored graph embeds the same kernel/query programs. Validate it in a
|
||||||
|
// short-lived scope and retain the representation that shares those trees.
|
||||||
|
const graphDigest = await (async () => {
|
||||||
|
const input = await readJson(root, "graph-execution.json", scope);
|
||||||
|
try {
|
||||||
|
const digest = await hexDigestAsync(input.value);
|
||||||
|
if (digest !== (await hexDigestAsync(expectedGraph)) || artifacts.execution !== digest)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"CANDIDATE_EXECUTION_MISMATCH",
|
||||||
|
root,
|
||||||
|
"Stored graph execution differs from checked inputs",
|
||||||
|
);
|
||||||
|
return digest;
|
||||||
|
} finally {
|
||||||
|
input.releaseTree();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const provenance = {
|
||||||
|
workspaceRevision: plan.definition.workspace.id as string,
|
||||||
|
source: { repository: source.repository as string, commit: source.commit as string },
|
||||||
|
generator: artifacts.generator as string,
|
||||||
|
packages: (plan as ExecutionPlan).definition.packages.map((pkg) => ({
|
||||||
|
revision: pkg.revision,
|
||||||
|
source: pkg.source,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const reference = {
|
||||||
|
root,
|
||||||
|
digest: hexDigest({
|
||||||
|
plan: plan.digest,
|
||||||
|
world: installation.world,
|
||||||
|
provenance,
|
||||||
|
roots: artifacts.roots,
|
||||||
|
entry,
|
||||||
|
programs: artifacts.programs,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const catalog = createClientContractCatalog(plan, installation, scope, kernel);
|
||||||
|
return {
|
||||||
|
reference,
|
||||||
|
workspace: plan.definition.workspace.workspaceId,
|
||||||
|
// The checked representation was already compared with this digest.
|
||||||
|
world: graphDigest,
|
||||||
|
plan: kernel.plan as string,
|
||||||
|
graphExecution: expectedGraph,
|
||||||
|
provenance,
|
||||||
|
entry,
|
||||||
|
kernel,
|
||||||
|
queries,
|
||||||
|
installation,
|
||||||
|
executionPlan: plan as ExecutionPlan,
|
||||||
|
clientContractCatalog: catalog.select,
|
||||||
|
metadata: {
|
||||||
|
get bytes() {
|
||||||
|
return scope.bytes;
|
||||||
|
},
|
||||||
|
release: () => {
|
||||||
|
catalog.close();
|
||||||
|
scope.release();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
scope.release();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compileWorkspaceEntry(plan: ExecutionPlan, entryRequest: unknown): { operation: string } | null {
|
||||||
|
let entry: { operation: string } | null = null;
|
||||||
|
if (entryRequest !== null) {
|
||||||
|
if (
|
||||||
|
typeof entryRequest !== "object" ||
|
||||||
|
Array.isArray(entryRequest) ||
|
||||||
|
Object.keys(entryRequest).sort().join(",") !== "atom,interface,operation"
|
||||||
|
)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"INVALID_ENTRY_FACTORY",
|
||||||
|
"entry",
|
||||||
|
"Entry declaration must name atom, interface and operation identities",
|
||||||
|
);
|
||||||
|
const operation = plan.definition.operations.find(
|
||||||
|
(op) =>
|
||||||
|
op.atomId === (entryRequest as Record<string, unknown>).atom &&
|
||||||
|
op.interfaceRevisionId === (entryRequest as Record<string, unknown>).interface &&
|
||||||
|
op.operationId === (entryRequest as Record<string, unknown>).operation,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!operation ||
|
||||||
|
operation.scope !== "class" ||
|
||||||
|
operation.execution.kind !== "transaction" ||
|
||||||
|
operation.input.kind !== "builtin" ||
|
||||||
|
operation.input.name !== "unit" ||
|
||||||
|
operation.output.kind !== "object-ref"
|
||||||
|
)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"INVALID_ENTRY_FACTORY",
|
||||||
|
"entry",
|
||||||
|
"Entry must be a checked unit-input class transaction returning one object reference",
|
||||||
|
);
|
||||||
|
entry = { operation: operation.key };
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import { executionPlanDigest, type ExecutionPlan, resolvePlannedOperation, ExecutionPlanError } from "./plan.js";
|
||||||
|
import type {
|
||||||
|
InterfaceRevision,
|
||||||
|
InterfaceOperation,
|
||||||
|
ExecutionContract,
|
||||||
|
PackageRevision,
|
||||||
|
} from "../capability-model/types.js";
|
||||||
|
import { canonicalJson } from "../capability-model/evolution.js";
|
||||||
|
import { portableValueSchema, tsType } from "../bindings/portable.js";
|
||||||
|
|
||||||
|
import type { BindingSchema } from "../bindings/index.js";
|
||||||
|
import type { PackageBuildContract } from "./build-contract.js";
|
||||||
|
import { preparationArtifact, type ReactPreparationArtifact } from "../react-build/declarations.js";
|
||||||
|
import { moduleTypeContext } from "./module-contracts.js";
|
||||||
|
import { queryTreeBlueprint, queryTreeType } from "./query-trees.js";
|
||||||
|
|
||||||
|
/** Public shape only: no slot inventory, implementation binding, or workspace identity. */
|
||||||
|
export interface PublicOperationContract {
|
||||||
|
interface: string;
|
||||||
|
operation: string;
|
||||||
|
scope: "object" | "class";
|
||||||
|
execution: ExecutionContract;
|
||||||
|
input: unknown;
|
||||||
|
output: unknown;
|
||||||
|
fieldPolicy?: "register" | "crdt";
|
||||||
|
}
|
||||||
|
export function publicOperationContract(
|
||||||
|
iface: InterfaceRevision,
|
||||||
|
operation: InterfaceOperation,
|
||||||
|
): PublicOperationContract {
|
||||||
|
if (iface.template || operation.mode !== "call" || !iface.members.some((m) => m.operations.includes(operation)))
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"UNSUPPORTED_CLIENT_CONTRACT",
|
||||||
|
iface.revisionId,
|
||||||
|
"Expected a closed interface call operation",
|
||||||
|
);
|
||||||
|
const execution = operation.execution ?? { kind: "remote" };
|
||||||
|
const member = iface.members.find((m) => m.operations.includes(operation))!;
|
||||||
|
const fieldPolicy =
|
||||||
|
member.kind === "value" && operation.scope !== "class" && execution.kind === "transaction"
|
||||||
|
? operation.displayName === "edit"
|
||||||
|
? "crdt"
|
||||||
|
: member.nativeField && operation.displayName === "set"
|
||||||
|
? "register"
|
||||||
|
: undefined
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
...(fieldPolicy ? { fieldPolicy } : {}),
|
||||||
|
interface: iface.revisionId,
|
||||||
|
operation: operation.id,
|
||||||
|
scope: operation.scope ?? "object",
|
||||||
|
execution: execution.kind === "remote" ? execution : { ...execution, targets: [...execution.targets].sort() },
|
||||||
|
input: portableValueSchema(operation.inputType),
|
||||||
|
output: portableValueSchema(operation.outputType),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** Resolve a public contract to internal dispatch. This does not authorize the receiver. */
|
||||||
|
export function linkInterfaceOperation(plan: ExecutionPlan, atom: string, contract: PublicOperationContract) {
|
||||||
|
if (executionPlanDigest(plan) !== plan.digest)
|
||||||
|
throw new ExecutionPlanError("INVALID_PLAN", atom, "Invalid execution plan digest");
|
||||||
|
const iface = plan.definition.workspace.interfaceImports.find((i) => i.revisionId === contract.interface);
|
||||||
|
const operation = iface?.members.flatMap((m) => m.operations).find((o) => o.id === contract.operation);
|
||||||
|
if (!iface || !operation || canonicalJson(publicOperationContract(iface, operation)) !== canonicalJson(contract))
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"CLIENT_CONTRACT_MISMATCH",
|
||||||
|
`${contract.interface}/${contract.operation}`,
|
||||||
|
"Installed interface differs from the imported operation contract",
|
||||||
|
);
|
||||||
|
return resolvePlannedOperation(plan, atom, contract.interface, contract.operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicQueryContract {
|
||||||
|
package: string;
|
||||||
|
query: string;
|
||||||
|
definitionDigest: string;
|
||||||
|
root: { kind: "object" | "workspace-interface"; interface: string };
|
||||||
|
placement: "server" | "replica" | "local";
|
||||||
|
sourceQuery?: string;
|
||||||
|
input: unknown;
|
||||||
|
output: unknown;
|
||||||
|
tree?: ReturnType<typeof queryTreeBlueprint>;
|
||||||
|
}
|
||||||
|
export function publicQueryContract(pkg: PackageRevision, queryId: string): PublicQueryContract {
|
||||||
|
const query = pkg.checkedQueries?.find((q) => q.declaration.id === queryId);
|
||||||
|
if (!query)
|
||||||
|
throw new ExecutionPlanError("UNCHECKED_QUERY", `${pkg.revisionId}/${queryId}`, "Expected a checked package query");
|
||||||
|
const placement = query.declaration.placement;
|
||||||
|
return {
|
||||||
|
package: pkg.revisionId,
|
||||||
|
query: queryId,
|
||||||
|
definitionDigest: query.definitionDigest,
|
||||||
|
root: query.declaration.root,
|
||||||
|
placement: placement?.kind ?? "server",
|
||||||
|
...(placement?.kind === "local" ? { sourceQuery: query.treeProjection?.tree ?? placement.sourceQueryId } : {}),
|
||||||
|
input: portableValueSchema(query.variables),
|
||||||
|
output: portableValueSchema(query.output),
|
||||||
|
...(query.tree ? { tree: queryTreeBlueprint(pkg.revisionId, query) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function linkPackageQuery(plan: ExecutionPlan, contract: PublicQueryContract) {
|
||||||
|
if (executionPlanDigest(plan) !== plan.digest)
|
||||||
|
throw new ExecutionPlanError("INVALID_PLAN", contract.query, "Invalid execution plan digest");
|
||||||
|
const pkg = plan.definition.workspace.packageImports.find((p) => p.revisionId === contract.package);
|
||||||
|
if (!pkg || canonicalJson(publicQueryContract(pkg, contract.query)) !== canonicalJson(contract))
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"CLIENT_QUERY_MISMATCH",
|
||||||
|
`${contract.package}/${contract.query}`,
|
||||||
|
"Installed query differs from the imported query contract",
|
||||||
|
);
|
||||||
|
const query = plan.definition.queries.find(
|
||||||
|
(q) => q.query.packageRevisionId === contract.package && q.query.checked.declaration.id === contract.query,
|
||||||
|
);
|
||||||
|
if (!query) throw new ExecutionPlanError("UNINSTALLED_QUERY", contract.query, "No installed query binding");
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate only the explicitly imported interfaces. No concrete atom inventory is needed. */
|
||||||
|
export function generateInterfaceClientBindings(
|
||||||
|
imports: Readonly<Record<string, InterfaceRevision>>,
|
||||||
|
runtimeModule = "@quixos/camino-replica-core",
|
||||||
|
moduleProps?: (interfaceId: string, memberId: string) => string,
|
||||||
|
): string {
|
||||||
|
const entries = Object.entries(imports).map(([alias, iface]) => {
|
||||||
|
if (iface.template)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"OPEN_CLIENT_CONTRACT",
|
||||||
|
alias,
|
||||||
|
"Specialize the interface before generating a client",
|
||||||
|
);
|
||||||
|
const members = iface.members.map((member) => {
|
||||||
|
if (member.kind === "module" && member.subject?.kind === "interface") {
|
||||||
|
const subject = member.subject.interfaceRevisionId;
|
||||||
|
return `${JSON.stringify(member.displayName)}: defineModuleMember<${JSON.stringify(subject)}, ${moduleProps?.(iface.revisionId, member.id) ?? "{}"}>(${JSON.stringify({ interface: iface.revisionId, member: member.id, subject, contract: member.contract, ...moduleTypeContext(iface) })})`;
|
||||||
|
}
|
||||||
|
const operations = member.operations
|
||||||
|
.filter((op) => op.mode === "call")
|
||||||
|
.map((op) => {
|
||||||
|
const contract = publicOperationContract(iface, op);
|
||||||
|
return contract.fieldPolicy && member.kind === "value"
|
||||||
|
? `${JSON.stringify(op.displayName)}: defineFieldOperation<${tsType(member.valueType)}, ${JSON.stringify(contract.fieldPolicy)}, ${JSON.stringify(contract.execution)}>(${JSON.stringify(contract)})`
|
||||||
|
: `${JSON.stringify(op.displayName)}: defineInterfaceOperation<${tsType(op.inputType)}, ${tsType(op.outputType)}, ${JSON.stringify(contract.execution)}>(${JSON.stringify(contract)})`;
|
||||||
|
});
|
||||||
|
return `${JSON.stringify(member.displayName)}: {${operations.join(",\n")}}`;
|
||||||
|
});
|
||||||
|
return `${JSON.stringify(alias)}: {${members.join(",\n")}}`;
|
||||||
|
});
|
||||||
|
return `// Generated from imported interface contracts. Regenerate; do not edit.\nimport { defineModuleMember, defineInterfaceOperation, defineFieldOperation, type PreparedChange, type ManagedReference } from ${JSON.stringify(runtimeModule)};\nexport const interfaces = {${entries.join(",\n")}} as const;\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatePackageQueryClientBindings(
|
||||||
|
pkg: PackageRevision,
|
||||||
|
runtimeModule = "@quixos/camino-replica-core",
|
||||||
|
packages: readonly PackageRevision[] = [pkg],
|
||||||
|
): string {
|
||||||
|
const internal =
|
||||||
|
(pkg.buildArtifacts?.[preparationArtifact] as ReactPreparationArtifact | undefined)?.internalQueries ?? [];
|
||||||
|
pkg = {
|
||||||
|
...pkg,
|
||||||
|
queries: pkg.queries?.filter((query) => !internal.includes(query.id)),
|
||||||
|
checkedQueries: pkg.checkedQueries?.filter((query) => !internal.includes(query.declaration.id)),
|
||||||
|
};
|
||||||
|
const queries = (pkg.checkedQueries ?? [])
|
||||||
|
.filter((query) => !query.declaration.fragment && !query.treeProjection)
|
||||||
|
.map(
|
||||||
|
(query) =>
|
||||||
|
`${JSON.stringify(query.declaration.displayName)}: ${query.tree ? `definePackageQueryTree<${tsType(query.variables)}, ${queryTreeType(query)}, ${JSON.stringify(query.declaration.root.interface)}>` : `definePackageQuery<${tsType(query.variables)}, ${tsType(query.output)}, ${JSON.stringify(publicQueryContract(pkg, query.declaration.id).placement)}>`}(${JSON.stringify(publicQueryContract(pkg, query.declaration.id))})`,
|
||||||
|
);
|
||||||
|
const emitFragment = (provider: PackageRevision, id: string) => {
|
||||||
|
const query = provider.checkedQueries?.find((query) => query.declaration.id === id);
|
||||||
|
if (
|
||||||
|
!query?.declaration.fragment ||
|
||||||
|
query.declaration.fragment.admission ||
|
||||||
|
query.declaration.placement?.kind !== "local" ||
|
||||||
|
query.output.kind !== "record" ||
|
||||||
|
!query.output.fields.root
|
||||||
|
)
|
||||||
|
throw new ExecutionPlanError("UNCHECKED_FRAGMENT", id, "Expected a checked local fragment export");
|
||||||
|
const output = query.output.fields.root;
|
||||||
|
const descriptor = {
|
||||||
|
package: provider.revisionId,
|
||||||
|
fragment: id,
|
||||||
|
digest: query.definitionDigest,
|
||||||
|
subject: query.declaration.root.interface,
|
||||||
|
input: portableValueSchema(query.variables),
|
||||||
|
output: portableValueSchema(output),
|
||||||
|
admission: publicQueryContract(provider, query.declaration.placement.sourceQueryId),
|
||||||
|
view: publicQueryContract(provider, id),
|
||||||
|
};
|
||||||
|
return `definePackageFragment<${tsType(query.variables)}, ${tsType(output)}, ${JSON.stringify(query.declaration.root.interface)}>(${JSON.stringify(descriptor)})`;
|
||||||
|
};
|
||||||
|
const fragments = (pkg.checkedQueries ?? [])
|
||||||
|
.filter((query) => query.declaration.fragment && !query.declaration.fragment.admission)
|
||||||
|
.map((query) => `${JSON.stringify(query.declaration.displayName)}: ${emitFragment(pkg, query.declaration.id)}`);
|
||||||
|
const imports = new Map<string, Map<string, string>>();
|
||||||
|
for (const query of pkg.queries ?? [])
|
||||||
|
for (const imported of query.importedFragments ?? []) {
|
||||||
|
if (imported.packageRevisionId === pkg.revisionId) continue;
|
||||||
|
const provider = packages.find((provider) => provider.revisionId === imported.packageRevisionId);
|
||||||
|
const name = provider?.queries?.find((query) => query.id === imported.fragmentId)?.displayName;
|
||||||
|
if (!provider || !name)
|
||||||
|
throw new ExecutionPlanError("UNCHECKED_FRAGMENT", imported.fragmentId, "Missing imported fragment provider");
|
||||||
|
const alias = imported.packageAlias ?? provider.displayName;
|
||||||
|
if (pkg.queries?.some((query) => query.fragment && query.displayName === alias))
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"FRAGMENT_ALIAS_COLLISION",
|
||||||
|
alias,
|
||||||
|
"Imported fragment namespace conflicts with a local fragment export",
|
||||||
|
);
|
||||||
|
const entries = imports.get(alias) ?? new Map<string, string>();
|
||||||
|
entries.set(name, emitFragment(provider, imported.fragmentId));
|
||||||
|
imports.set(alias, entries);
|
||||||
|
}
|
||||||
|
for (const [alias, entries] of imports)
|
||||||
|
fragments.push(
|
||||||
|
`${JSON.stringify(alias)}: {${[...entries].map(([name, value]) => `${JSON.stringify(name)}: ${value}`).join(",")}}`,
|
||||||
|
);
|
||||||
|
return `// Generated from checked package query contracts. Regenerate; do not edit.\nimport { definePackageQuery, definePackageFragment, ${queries.some((query) => query.includes("definePackageQueryTree<")) ? "definePackageQueryTree, type QueryTreeShape, type QueryTreeOccurrence, " : ""}type ManagedReference } from ${JSON.stringify(runtimeModule)};\nexport const queries = {${queries.join(",\n")}} as const;\nexport const fragments = {${fragments.join(",\n")}} as const;\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Receipt-covered bindings expose the package's imports, component subjects and
|
||||||
|
* declared queries, never the workspace's complete atom/interface inventory. */
|
||||||
|
export function generateComponentClientBindings(plan: ExecutionPlan, packageRevision: string): string {
|
||||||
|
const shape = plan.definition.packages.find((pkg) => pkg.revision === packageRevision);
|
||||||
|
if (!shape) throw new ExecutionPlanError("NO_COMPONENT_CONTRACT", packageRevision, "Unknown package");
|
||||||
|
return generatePackageComponentBindings(plan.definition.bindingSchema, shape);
|
||||||
|
}
|
||||||
|
export function generatePackageComponentBindings(
|
||||||
|
schema: BindingSchema,
|
||||||
|
shape: PackageBuildContract["definition"]["package"],
|
||||||
|
): string {
|
||||||
|
const packageRevision = shape.revision;
|
||||||
|
const pkg = schema.packages.find((pkg) => pkg.revisionId === packageRevision);
|
||||||
|
const components = shape.components;
|
||||||
|
const modules = shape.modules ?? [];
|
||||||
|
if (!pkg || !components?.length)
|
||||||
|
throw new ExecutionPlanError("NO_COMPONENT_CONTRACT", packageRevision, "Package declares no component entries");
|
||||||
|
const byId = new Map(schema.interfaces.map((iface) => [iface.revisionId as string, iface]));
|
||||||
|
const find = (id: string) => {
|
||||||
|
const contract = byId.get(id);
|
||||||
|
if (!contract || contract.template)
|
||||||
|
throw new ExecutionPlanError("OPEN_CLIENT_CONTRACT", id, "Component contracts must be closed");
|
||||||
|
return contract;
|
||||||
|
};
|
||||||
|
const imports = Object.fromEntries(
|
||||||
|
Object.entries(pkg.clientInterfaces ?? {}).map(([alias, id]) => [alias, find(id)]),
|
||||||
|
);
|
||||||
|
const subjects = Object.fromEntries(components.map((component) => [component.displayName, find(component.subject)]));
|
||||||
|
const props = (iface: string, member: string) =>
|
||||||
|
`ExtraModuleProps<${JSON.stringify(JSON.stringify([iface, member]))}>`;
|
||||||
|
// Preserve closed type arguments for the artifact consumer. Interpretation of
|
||||||
|
// a particular module contract belongs to that consumer, not this compiler.
|
||||||
|
const moduleArguments = (iface: InterfaceRevision) =>
|
||||||
|
`[${(iface.application?.arguments ?? [])
|
||||||
|
.map((argument) => {
|
||||||
|
if (argument.kind === "value") return tsType(argument.type);
|
||||||
|
const target = argument.target;
|
||||||
|
const reference =
|
||||||
|
target.kind === "atom"
|
||||||
|
? `ManagedReference<"atom", ${JSON.stringify(target.atomId)}>`
|
||||||
|
: `ManagedReference<"interface", ${JSON.stringify(target.interfaceRevisionId)}>`;
|
||||||
|
return `{readonly reference: ${reference}}`;
|
||||||
|
})
|
||||||
|
.join(",")}]`;
|
||||||
|
const interfaces = generateInterfaceClientBindings(imports, "@quixos/camino-react", props);
|
||||||
|
const subjectBindings = generateInterfaceClientBindings(subjects, "@quixos/camino-react", props)
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => !line.startsWith("import "))
|
||||||
|
.join("\n")
|
||||||
|
.replace("export const interfaces =", "export const subjects =");
|
||||||
|
const queries = generatePackageQueryClientBindings(pkg, "@quixos/camino-react", schema.packages)
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => !line.startsWith("import "))
|
||||||
|
.join("\n");
|
||||||
|
return (
|
||||||
|
interfaces +
|
||||||
|
'\nimport {createObjectRenderer,definePackageQuery,definePackageFragment,definePackageQueryTree,type QueryTreeShape,type QueryTreeOccurrence,type QueryTreeHandle,type ComponentType,type ComponentProps,type PresentationHandle} from "@quixos/camino-react";\n' +
|
||||||
|
subjectBindings +
|
||||||
|
"\n" +
|
||||||
|
queries +
|
||||||
|
`\nexport const providers = {${Object.entries(pkg.clientPackages ?? {})
|
||||||
|
.map(([alias, id]) => {
|
||||||
|
const imported = schema.packages.find((entry) => entry.revisionId === id);
|
||||||
|
if (!imported) throw new ExecutionPlanError("UNINSTALLED_PACKAGE", id, "Missing imported module provider");
|
||||||
|
return `${JSON.stringify(alias)}:{${(imported.modules ?? []).map((module) => `${JSON.stringify(module.displayName)}:${JSON.stringify({ packageRevision: id, id: module.id })}`).join(",")}}`;
|
||||||
|
})
|
||||||
|
.join(",")}} as const;\n` +
|
||||||
|
`\nexport const renderers = {${Object.entries(imports)
|
||||||
|
.map(([name, iface]) => `${JSON.stringify(name)}:createObjectRenderer(${JSON.stringify(iface.revisionId)})`)
|
||||||
|
.join(",\n")}} as const;\n` +
|
||||||
|
"\nexport interface ModuleProps {}\n" +
|
||||||
|
"export type ExtraModuleProps<K extends string> = K extends keyof ModuleProps ? ModuleProps[K] : {};\n" +
|
||||||
|
"export interface Modules {\n" +
|
||||||
|
components
|
||||||
|
.map((component) => {
|
||||||
|
const module = modules.find((module) => module.id === component.id)!;
|
||||||
|
const key = JSON.stringify([module.interfaceRevisionId, module.memberId]);
|
||||||
|
const artifact = pkg.buildArtifacts?.["org.quixos.react.preparation/1"] as
|
||||||
|
| import("../react-build/declarations.js").ReactPreparationArtifact
|
||||||
|
| undefined;
|
||||||
|
const preparation = artifact?.modules.find((entry) => entry.module === module.id);
|
||||||
|
const composition = pkg.queryCompositions?.find((entry) => entry.id === preparation?.composition);
|
||||||
|
if (!preparation || !composition)
|
||||||
|
throw new ExecutionPlanError("MISSING_PRESENTATION_INPUTS", module.id, "Declare inputs in quixos.react.json");
|
||||||
|
const children = preparation.children.map((child) => {
|
||||||
|
let extra = "{}";
|
||||||
|
if (child.role) {
|
||||||
|
const iface = find(child.role.interface);
|
||||||
|
const member = iface.members.find(
|
||||||
|
(entry) => entry.id === child.role!.member || entry.displayName === child.role!.member,
|
||||||
|
)!;
|
||||||
|
extra = props(iface.revisionId, member.id);
|
||||||
|
}
|
||||||
|
const handle = `PresentationHandle<${JSON.stringify(child.subject)}, ${extra}>`;
|
||||||
|
return `readonly ${JSON.stringify(child.name)}: ${child.each ? `readonly ${handle}[]` : child.with || child.when ? `${handle} | null` : handle}`;
|
||||||
|
});
|
||||||
|
const trees = (preparation.trees ?? []).map((tree) => {
|
||||||
|
const query = pkg.checkedQueries?.find((query) => query.declaration.id === tree.query);
|
||||||
|
if (!query?.tree)
|
||||||
|
throw new ExecutionPlanError("MISSING_QUERY_TREE", tree.query, "Expected a checked Query Tree");
|
||||||
|
return `readonly ${JSON.stringify(tree.name)}: QueryTreeHandle<${queryTreeType(query)}>`;
|
||||||
|
});
|
||||||
|
const input = `${tsType(preparation.input ?? composition.output)} & {readonly children: {${children.join(";")}}${trees.length ? `;readonly trees:{${trees.join(";")}}` : ""}}`;
|
||||||
|
return `${JSON.stringify(component.displayName)}: ComponentType<ComponentProps<${JSON.stringify(component.subject)}, ${JSON.stringify(module.contract)}, ${moduleArguments(find(module.interfaceRevisionId))}, ${input}> & ExtraModuleProps<${JSON.stringify(key)}>>;`;
|
||||||
|
})
|
||||||
|
.join("\n") +
|
||||||
|
"\n}\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function componentAssetDeclarations(): string {
|
||||||
|
return (
|
||||||
|
"// Generated component asset modules. Regenerate; do not edit.\n" +
|
||||||
|
["svg", "png", "jpg", "jpeg", "gif", "webp", "avif", "woff", "woff2", "ttf", "ico"]
|
||||||
|
.map((extension) => `declare module "*.${extension}" { const value: string; export default value; }`)
|
||||||
|
.join("\n") +
|
||||||
|
'\ndeclare module "*.css";\n'
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { executionPlanDigestAsync, type ExecutionPlan, hexDigest, hexDigestAsync, ExecutionPlanError } from "./plan.js";
|
||||||
|
import { portableReflectionValue } from "./reflection.js";
|
||||||
|
import type {
|
||||||
|
BoundDependency,
|
||||||
|
DependencyBinding,
|
||||||
|
PackageExport,
|
||||||
|
PersistentAttachment,
|
||||||
|
} from "../capability-model/types.js";
|
||||||
|
import { type ExecutionInstallation, type ExecutionGroupIdentity } from "./installation.js";
|
||||||
|
|
||||||
|
export interface ExecutionEnvironment {
|
||||||
|
/** Explicit host placement. Merely including an optional module does not select it. */
|
||||||
|
readonly placement: "service" | "module";
|
||||||
|
/** Exact host executable/isolation inputs, computed by the trusted installer. */
|
||||||
|
readonly hostDigest: string;
|
||||||
|
readonly policyDigest: string;
|
||||||
|
/** Identifier of the secret/config generation; never secret material. */
|
||||||
|
readonly credentialGeneration: string;
|
||||||
|
}
|
||||||
|
export interface InstalledExecutionGroup extends ExecutionGroupIdentity {
|
||||||
|
readonly packageRevision: string;
|
||||||
|
readonly consumedPackages: readonly string[];
|
||||||
|
}
|
||||||
|
/** Conservative outbound closure. Runtime graph values, reverse callers, source
|
||||||
|
* provenance and the workspace epoch are deliberately absent from this identity. */
|
||||||
|
export async function compileExecutionGroups(
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
installation: ExecutionInstallation,
|
||||||
|
environments: Readonly<Record<string, ExecutionEnvironment>>,
|
||||||
|
): Promise<InstalledExecutionGroup[]> {
|
||||||
|
if (installation.plan !== plan.digest || (await executionPlanDigestAsync(plan)) !== plan.digest)
|
||||||
|
throw new ExecutionPlanError("GROUP_PLAN_MISMATCH", plan.digest, "Expected one checked installation");
|
||||||
|
const workspace = plan.definition.workspace;
|
||||||
|
const packages = new Map(workspace.packageImports.map((p) => [p.revisionId as string, p]));
|
||||||
|
const operations = new Map(plan.definition.operations.map((op) => [op.key, op]));
|
||||||
|
const attachments = new Map<string, PersistentAttachment>(
|
||||||
|
[...workspace.sharedAttachments, ...workspace.conformances.flatMap((c) => c.privateAttachments)].map((a) => [
|
||||||
|
a.id,
|
||||||
|
a,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const logical = (revision: string) => {
|
||||||
|
const pkg = packages.get(revision);
|
||||||
|
if (!pkg) throw new ExecutionPlanError("GROUP_PACKAGE_MISSING", revision, "Uninstalled consumed package");
|
||||||
|
return pkg.packageId;
|
||||||
|
};
|
||||||
|
const environment = (revision: string) => {
|
||||||
|
const value = environments[revision];
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
!["service", "module"].includes(value.placement) ||
|
||||||
|
!/^[0-9a-f]{64}$/.test(value.hostDigest) ||
|
||||||
|
!/^[0-9a-f]{64}$/.test(value.policyDigest) ||
|
||||||
|
!value.credentialGeneration ||
|
||||||
|
value.credentialGeneration.length > 4096
|
||||||
|
)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"GROUP_ENVIRONMENT_MISSING",
|
||||||
|
revision,
|
||||||
|
"Expected checked host/policy digests and a credential generation",
|
||||||
|
);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const implementations = new Map(
|
||||||
|
installation.packages.map((pkg) => {
|
||||||
|
if (!pkg.service && !pkg.module) return [pkg.revision, undefined] as const;
|
||||||
|
const implementation = pkg[environment(pkg.revision).placement];
|
||||||
|
if (!implementation)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"GROUP_IMPLEMENTATION_MISSING",
|
||||||
|
pkg.revision,
|
||||||
|
"Host selected an unavailable implementation",
|
||||||
|
);
|
||||||
|
return [pkg.revision, implementation] as const;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const attachmentContract = (value: PersistentAttachment) => {
|
||||||
|
const { displayName: _name, ...rest } = value;
|
||||||
|
return value.kind === "state"
|
||||||
|
? rest
|
||||||
|
: { ...rest, endpoints: value.endpoints.map(({ displayName: _name, ...endpoint }) => endpoint) };
|
||||||
|
};
|
||||||
|
const bindingContract = (value: DependencyBinding) =>
|
||||||
|
value.kind === "query" ? { ...value, packageRevisionId: logical(value.packageRevisionId) } : value;
|
||||||
|
const groups: InstalledExecutionGroup[] = [];
|
||||||
|
for (const root of installation.packages.filter((p) => !!implementations.get(p.revision))) {
|
||||||
|
const consumed = new Map<string, unknown>(),
|
||||||
|
visited = new Set<string>(),
|
||||||
|
visitedQueries = new Set<string>(),
|
||||||
|
consumedPackages = new Set<string>();
|
||||||
|
const remember = (key: string, value: unknown) => consumed.set(key, value);
|
||||||
|
const storage = (id: string) => {
|
||||||
|
const value = attachments.get(id);
|
||||||
|
if (!value) throw new ExecutionPlanError("GROUP_STORAGE_MISSING", id, "Uninstalled consumed storage");
|
||||||
|
remember(`storage:${id}`, attachmentContract(value));
|
||||||
|
};
|
||||||
|
// Deletion may visit related objects and their owned state. Conservatively
|
||||||
|
// include the connected schema component, including possible future edges.
|
||||||
|
const lifecycle = (atom: string) => {
|
||||||
|
const visited = new Set<string>(),
|
||||||
|
pending = [atom];
|
||||||
|
const atoms = (constraint: Extract<PersistentAttachment, { kind: "edge" }>["endpoints"][number]["constraint"]) =>
|
||||||
|
constraint.kind === "atom"
|
||||||
|
? [constraint.atomId as string]
|
||||||
|
: workspace.conformances
|
||||||
|
.filter((c) => c.interfaceRevisionId === constraint.interfaceRevisionId)
|
||||||
|
.map((c) => c.atomId as string);
|
||||||
|
while (pending.length) {
|
||||||
|
const current = pending.pop()!;
|
||||||
|
if (visited.has(current)) continue;
|
||||||
|
visited.add(current);
|
||||||
|
for (const owner of workspace.conformances.filter((c) => c.atomId === current))
|
||||||
|
for (const attachment of owner.privateAttachments) storage(attachment.id);
|
||||||
|
for (const attachment of attachments.values())
|
||||||
|
if (attachment.kind === "edge" && attachment.endpoints.some((e) => atoms(e.constraint).includes(current))) {
|
||||||
|
storage(attachment.id);
|
||||||
|
pending.push(...attachment.endpoints.flatMap((e) => atoms(e.constraint)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const targets = (binding: DependencyBinding, atom: string): string[] => {
|
||||||
|
if (binding.kind === "interface" && binding.receiver === "input")
|
||||||
|
return workspace.conformances
|
||||||
|
.filter((c) => c.interfaceRevisionId === binding.interfaceRevisionId)
|
||||||
|
.map((c) => c.atomId);
|
||||||
|
if (!("via" in binding) || !binding.via) return [atom];
|
||||||
|
storage(binding.via.edgeTypeId);
|
||||||
|
const edge = attachments.get(binding.via.edgeTypeId);
|
||||||
|
if (!edge || edge.kind !== "edge")
|
||||||
|
throw new ExecutionPlanError("GROUP_EDGE_MISSING", atom, "Missing traversal edge");
|
||||||
|
const endpoint = edge.endpoints.find((e) => e.projectionId !== binding.via!.projectionId)!;
|
||||||
|
const constraint = endpoint.constraint;
|
||||||
|
return constraint.kind === "atom"
|
||||||
|
? [constraint.atomId]
|
||||||
|
: workspace.conformances
|
||||||
|
.filter((c) => c.interfaceRevisionId === constraint.interfaceRevisionId)
|
||||||
|
.map((c) => c.atomId);
|
||||||
|
};
|
||||||
|
const query = (revision: string, id: string) => {
|
||||||
|
const key = JSON.stringify([revision, id]);
|
||||||
|
if (visitedQueries.has(key)) return;
|
||||||
|
visitedQueries.add(key);
|
||||||
|
const value = plan.definition.queries.find((q) => q.id === key);
|
||||||
|
if (!value) throw new ExecutionPlanError("GROUP_QUERY_MISSING", key, "Missing consumed query");
|
||||||
|
remember(`query:${JSON.stringify([logical(revision), id])}`, value.query.checked.definitionDigest);
|
||||||
|
for (const operation of value.operations) visit(operation);
|
||||||
|
};
|
||||||
|
const dependencies = (entry: PackageExport, bindings: readonly BoundDependency[], atom: string) => {
|
||||||
|
for (const bound of bindings) {
|
||||||
|
const target = bound.binding,
|
||||||
|
port = entry.dependencyPorts.find((p) => p.id === bound.portId)!;
|
||||||
|
if ("via" in target && target.via) storage(target.via.edgeTypeId);
|
||||||
|
if (target.kind === "reflection") {
|
||||||
|
for (const selector of target.selectors)
|
||||||
|
remember("reflection:" + selector, portableReflectionValue(workspace, selector));
|
||||||
|
} else if (target.kind === "state") storage(target.slotId);
|
||||||
|
else if (target.kind === "edge") storage(target.edgeTypeId);
|
||||||
|
else if (target.kind === "query") query(target.packageRevisionId, target.queryId);
|
||||||
|
else if (target.kind === "interface" && port.requirement.kind === "interface") {
|
||||||
|
const iface = workspace.interfaceImports.find((i) => i.revisionId === target.interfaceRevisionId)!;
|
||||||
|
for (const targetAtom of targets(target, atom))
|
||||||
|
for (const op of port.requirement.operations ?? iface.members.flatMap((m) => m.operations.map((o) => o.id)))
|
||||||
|
visit(JSON.stringify([targetAtom, target.interfaceRevisionId, op]));
|
||||||
|
} else if (target.kind === "creation") {
|
||||||
|
for (const seed of target.owners) {
|
||||||
|
const owner = workspace.conformances.find(
|
||||||
|
(c) => c.atomId === target.atomId && c.interfaceRevisionId === seed.interfaceRevisionId,
|
||||||
|
)!;
|
||||||
|
remember(`birth:${owner.id}`, {
|
||||||
|
id: owner.id,
|
||||||
|
atom: owner.atomId,
|
||||||
|
interface: owner.interfaceRevisionId,
|
||||||
|
initialization: owner.initialization ?? [],
|
||||||
|
});
|
||||||
|
for (const attachment of owner.privateAttachments) storage(attachment.id);
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"GROUP_DEPENDENCY_UNSUPPORTED",
|
||||||
|
bound.portId,
|
||||||
|
"Cannot establish execution reuse for this dependency",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const visit = (key: string) => {
|
||||||
|
if (visited.has(key)) return;
|
||||||
|
visited.add(key);
|
||||||
|
const operation = operations.get(key);
|
||||||
|
if (!operation) throw new ExecutionPlanError("GROUP_OPERATION_MISSING", key, "Missing consumed operation");
|
||||||
|
const binding = operation.binding;
|
||||||
|
if (binding.kind === "package") {
|
||||||
|
const pkg = packages.get(binding.packageRevisionId)!;
|
||||||
|
const implementation = implementations.get(pkg.revisionId);
|
||||||
|
if (!implementation)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"GROUP_IMPLEMENTATION_MISSING",
|
||||||
|
pkg.revisionId,
|
||||||
|
"Consumed package has no selected server implementation",
|
||||||
|
);
|
||||||
|
consumedPackages.add(pkg.packageId);
|
||||||
|
remember(`implementation:${pkg.packageId}`, {
|
||||||
|
digest: implementation.contentDigest,
|
||||||
|
environment: environment(pkg.revisionId),
|
||||||
|
});
|
||||||
|
const entry = pkg.exports.find((e) => e.id === binding.exportId)!;
|
||||||
|
const bindings = binding.dependencies.map((d) => ({ portId: d.portId, binding: bindingContract(d.binding) }));
|
||||||
|
const acceptance = (binding.acceptanceDependencies ?? []).map((d) => ({
|
||||||
|
portId: d.portId,
|
||||||
|
binding: bindingContract(d.binding),
|
||||||
|
}));
|
||||||
|
const contract = {
|
||||||
|
input: operation.input,
|
||||||
|
output: operation.output,
|
||||||
|
execution: operation.execution,
|
||||||
|
scope: operation.scope,
|
||||||
|
binding: {
|
||||||
|
package: pkg.packageId,
|
||||||
|
export: binding.exportId,
|
||||||
|
dependencies: bindings,
|
||||||
|
acceptanceDependencies: acceptance,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Deduplicate aliases of the same bound export, rather than keying its owner by reverse callers.
|
||||||
|
remember(`binding:${hexDigest(contract)}`, contract);
|
||||||
|
dependencies(entry, binding.dependencies, operation.atomId);
|
||||||
|
if (entry.acceptanceExportId)
|
||||||
|
dependencies(
|
||||||
|
pkg.exports.find((e) => e.id === entry.acceptanceExportId)!,
|
||||||
|
binding.acceptanceDependencies ?? [],
|
||||||
|
operation.atomId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
remember(`operation:${key}`, operation);
|
||||||
|
if (binding.kind === "state") storage(binding.slotId);
|
||||||
|
if (binding.kind === "edge") storage(binding.edgeTypeId);
|
||||||
|
if (binding.kind === "lifecycle") lifecycle(operation.atomId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const operation of plan.definition.operations)
|
||||||
|
if (operation.binding.kind === "package" && operation.binding.packageRevisionId === root.revision)
|
||||||
|
visit(operation.key);
|
||||||
|
const own = implementations.get(root.revision)!;
|
||||||
|
const exports = packages.get(root.revision)!.exports.map(({ displayName: _name, dependencyPorts, ...entry }) => ({
|
||||||
|
...entry,
|
||||||
|
dependencyPorts: dependencyPorts.map(({ displayName: _name, ...port }) => port),
|
||||||
|
}));
|
||||||
|
const entries = [...consumed].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||||
|
groups.push({
|
||||||
|
logicalId: logical(root.revision),
|
||||||
|
packageRevision: root.revision,
|
||||||
|
executionDigest: `sha256:${await hexDigestAsync({ implementation: own.contentDigest, exports, environment: environment(root.revision), versions: plan.definition.versions, consumed: entries })}`,
|
||||||
|
consumedPackages: [...consumedPackages].sort(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { constants } from "node:fs";
|
||||||
|
import { lstat, open, realpath } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
artifactPath,
|
||||||
|
parsePackageArtifacts,
|
||||||
|
checkExecutionWorld,
|
||||||
|
checkedWorldDigest,
|
||||||
|
type ArtifactReader,
|
||||||
|
type ArtifactFile,
|
||||||
|
type PackageArtifacts,
|
||||||
|
type CheckedWorld,
|
||||||
|
} from "./artifacts.js";
|
||||||
|
import { type ExecutionPlan, ExecutionPlanError } from "./plan.js";
|
||||||
|
import { readCandidateMetadata, type CandidateMetadataScope } from "./metadata.js";
|
||||||
|
|
||||||
|
const maxFileBytes = 256 * 1024 * 1024;
|
||||||
|
const metadataLimit = 16 * 1024 * 1024;
|
||||||
|
const fail = (where: string, message: string): never => {
|
||||||
|
throw new ExecutionPlanError("ARTIFACT_FILE_INVALID", where, message);
|
||||||
|
};
|
||||||
|
/** Roots must be trusted immutable output directories. Refuse symlinks, including
|
||||||
|
* interior directory links. Hash verification is still required before serving bytes. */
|
||||||
|
async function artifactFile(root: string, relative: string): Promise<string> {
|
||||||
|
artifactPath(relative);
|
||||||
|
const components = relative.split("/");
|
||||||
|
let resolved = root;
|
||||||
|
for (let index = 0; index < components.length; index++) {
|
||||||
|
resolved = path.join(resolved, components[index]!);
|
||||||
|
const stat = await lstat(resolved);
|
||||||
|
if (stat.isSymbolicLink() || (index < components.length - 1 ? !stat.isDirectory() : !stat.isFile()))
|
||||||
|
fail(relative, "Only real directories and regular files are allowed");
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
export async function artifactFileReader(roots: Readonly<Record<string, string>>): Promise<ArtifactReader> {
|
||||||
|
const directories = new Map<string, string>();
|
||||||
|
for (const [revision, root] of Object.entries(roots)) {
|
||||||
|
if (!path.isAbsolute(root) || path.normalize(root) !== root || (await realpath(root)) !== root)
|
||||||
|
fail(revision, "Output root must be a canonical absolute directory");
|
||||||
|
const stat = await lstat(root);
|
||||||
|
if (!stat.isDirectory() || stat.isSymbolicLink()) fail(root, "Output root is not a directory");
|
||||||
|
directories.set(revision, root);
|
||||||
|
}
|
||||||
|
return async (revision, relative, limit) => {
|
||||||
|
const root = directories.get(revision);
|
||||||
|
if (!root) fail(revision, "Unknown package output");
|
||||||
|
if (!Number.isSafeInteger(limit) || limit < 0 || limit > maxFileBytes) fail(relative, "Invalid file byte limit");
|
||||||
|
const resolved = await artifactFile(root!, relative);
|
||||||
|
const handle = await open(resolved, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||||
|
try {
|
||||||
|
const stat = await handle.stat();
|
||||||
|
if (!stat.isFile() || stat.size > limit) fail(relative, "File exceeds its declared bound");
|
||||||
|
// A fixed allocation and one extra byte detect growth without readFile's unbounded allocation.
|
||||||
|
const buffer = Buffer.alloc(stat.size + 1);
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < buffer.length) {
|
||||||
|
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
|
||||||
|
if (!bytesRead) break;
|
||||||
|
offset += bytesRead;
|
||||||
|
}
|
||||||
|
if (offset !== stat.size) fail(relative, "File changed while reading");
|
||||||
|
return buffer.subarray(0, offset);
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface LoadedArtifactWorld {
|
||||||
|
world: CheckedWorld;
|
||||||
|
digest: string;
|
||||||
|
manifests: PackageArtifacts[];
|
||||||
|
read: ArtifactReader;
|
||||||
|
}
|
||||||
|
/** Immutable-output loader. No module code is imported into the coordinator. */
|
||||||
|
export async function loadArtifactWorld(
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
roots: Readonly<Record<string, string>>,
|
||||||
|
metadata?: CandidateMetadataScope,
|
||||||
|
): Promise<LoadedArtifactWorld> {
|
||||||
|
const read = await artifactFileReader(roots),
|
||||||
|
manifests: PackageArtifacts[] = [];
|
||||||
|
const expected = plan.definition.packages.map((p) => p.revision).sort();
|
||||||
|
if (JSON.stringify(Object.keys(roots).sort()) !== JSON.stringify(expected))
|
||||||
|
fail("world", "Output roots must exactly match the installed plan");
|
||||||
|
for (const revision of expected) {
|
||||||
|
const file = "share/quixos/package-artifacts.json";
|
||||||
|
const input = metadata
|
||||||
|
? await readCandidateMetadata(await artifactFile(roots[revision]!, file), metadata, metadataLimit)
|
||||||
|
: { bytes: await read(revision, file, metadataLimit), releaseInput: () => {} };
|
||||||
|
let manifest: PackageArtifacts;
|
||||||
|
try {
|
||||||
|
manifest = parsePackageArtifacts(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(input.bytes)));
|
||||||
|
} finally {
|
||||||
|
input.releaseInput();
|
||||||
|
}
|
||||||
|
if (manifest.packageRevisionId !== revision) fail(revision, "Output belongs to another package revision");
|
||||||
|
manifests.push(manifest);
|
||||||
|
}
|
||||||
|
const world = await checkExecutionWorld(plan, manifests, read);
|
||||||
|
return { world, digest: checkedWorldDigest(world), manifests, read };
|
||||||
|
}
|
||||||
|
export interface BrowserArtifact {
|
||||||
|
url: string;
|
||||||
|
implementation: string;
|
||||||
|
packageRevisionId: string;
|
||||||
|
registryExport: string;
|
||||||
|
exports: readonly string[];
|
||||||
|
}
|
||||||
|
export interface ArtifactResponse {
|
||||||
|
status: 200 | 304;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: Uint8Array;
|
||||||
|
}
|
||||||
|
/** Shared runtime imports use content identity, independent of the importing package/world.
|
||||||
|
* The file remains covered by every importing implementation's checked manifest. */
|
||||||
|
export function contentArtifactUrl(file: Pick<ArtifactFile, "path" | "digest">): string {
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(file.digest)) fail(file.path, "Invalid content digest");
|
||||||
|
artifactPath(file.path);
|
||||||
|
return `/__quixos/artifacts/content/${file.digest}/${encodeURIComponent(path.basename(file.path))}`;
|
||||||
|
}
|
||||||
|
/** This registry is placed behind the workspace gateway's existing authentication.
|
||||||
|
* Lookup uses an explicit URL table; request paths never become filesystem paths. */
|
||||||
|
export class BrowserArtifactGateway {
|
||||||
|
readonly entries: readonly BrowserArtifact[];
|
||||||
|
private readonly routes = new Map<string, { revision: string; file: ArtifactFile }>();
|
||||||
|
private readonly read: ArtifactReader;
|
||||||
|
constructor(loaded: LoadedArtifactWorld) {
|
||||||
|
this.read = loaded.read;
|
||||||
|
const entries: BrowserArtifact[] = [];
|
||||||
|
for (const manifest of loaded.manifests)
|
||||||
|
for (const implementation of manifest.implementations) {
|
||||||
|
if (
|
||||||
|
implementation.target !== "target:org.quixos.execution.javascript@1" ||
|
||||||
|
implementation.compatibility !== "quixos-js-module-v1"
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const prefix = `/__quixos/artifacts/${loaded.digest}/${implementation.contentDigest}/`;
|
||||||
|
for (const file of implementation.files) {
|
||||||
|
if (!/^[a-zA-Z0-9.+-]+\/[a-zA-Z0-9.+-]+$/.test(file.mediaType)) fail(file.path, "Invalid asset MIME type");
|
||||||
|
const url = prefix + file.path.split("/").map(encodeURIComponent).join("/");
|
||||||
|
const previous = this.routes.get(url);
|
||||||
|
if (
|
||||||
|
previous &&
|
||||||
|
(previous.file.digest !== file.digest ||
|
||||||
|
previous.file.bytes !== file.bytes ||
|
||||||
|
previous.file.mediaType !== file.mediaType)
|
||||||
|
)
|
||||||
|
fail(url, "Conflicting browser asset route");
|
||||||
|
this.routes.set(url, { revision: manifest.packageRevisionId, file: Object.freeze({ ...file }) });
|
||||||
|
// Exact shared-file names are a packaging convention, never a path lookup capability.
|
||||||
|
if (file.path.startsWith("share/quixos/shared/")) {
|
||||||
|
const contentUrl = contentArtifactUrl(file);
|
||||||
|
const existing = this.routes.get(contentUrl);
|
||||||
|
if (existing && (existing.file.bytes !== file.bytes || existing.file.mediaType !== file.mediaType))
|
||||||
|
fail(contentUrl, "Conflicting shared asset metadata");
|
||||||
|
this.routes.set(contentUrl, { revision: manifest.packageRevisionId, file: Object.freeze({ ...file }) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.push({
|
||||||
|
url: prefix + implementation.path.split("/").map(encodeURIComponent).join("/"),
|
||||||
|
implementation: implementation.id,
|
||||||
|
packageRevisionId: manifest.packageRevisionId,
|
||||||
|
registryExport: implementation.registryExport!,
|
||||||
|
exports: Object.freeze([...implementation.exports]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.entries = Object.freeze(entries.map((entry) => Object.freeze(entry)));
|
||||||
|
}
|
||||||
|
async response(url: string, ifNoneMatch?: string): Promise<ArtifactResponse | undefined> {
|
||||||
|
const route = this.routes.get(url);
|
||||||
|
if (!route) return undefined;
|
||||||
|
const bytes = await this.read(route.revision, route.file.path, route.file.bytes);
|
||||||
|
if (bytes.length !== route.file.bytes || createHash("sha256").update(bytes).digest("hex") !== route.file.digest)
|
||||||
|
fail(url, "Browser asset changed after world validation");
|
||||||
|
const etag = `"sha256-${route.file.digest}"`;
|
||||||
|
const headers = {
|
||||||
|
"content-type": route.file.mediaType,
|
||||||
|
"cache-control": "private, max-age=31536000, immutable",
|
||||||
|
etag,
|
||||||
|
"x-content-type-options": "nosniff",
|
||||||
|
};
|
||||||
|
return ifNoneMatch === etag
|
||||||
|
? { status: 304, headers, body: new Uint8Array() }
|
||||||
|
: { status: 200, headers, body: bytes };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { executionPlanDigest, ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js";
|
||||||
|
|
||||||
|
import type { ExecutionInstallation } from "./installation.js";
|
||||||
|
|
||||||
|
/** Execution inputs, not build evidence. Nominal revision IDs remain dispatch
|
||||||
|
* keys: replacing a contract with a structurally similar one is not continuity.
|
||||||
|
* Host placement/policy/credentials are compared separately by execution groups. */
|
||||||
|
export function graphExecutionRepresentation(
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
installation: ExecutionInstallation,
|
||||||
|
kernel: unknown,
|
||||||
|
queries: unknown,
|
||||||
|
) {
|
||||||
|
if (installation.plan !== plan.digest || executionPlanDigest(plan) !== plan.digest)
|
||||||
|
throw new ExecutionPlanError("GRAPH_PLAN_MISMATCH", "graph", "Expected one checked installation");
|
||||||
|
if (
|
||||||
|
!kernel ||
|
||||||
|
typeof kernel !== "object" ||
|
||||||
|
Array.isArray(kernel) ||
|
||||||
|
!queries ||
|
||||||
|
typeof queries !== "object" ||
|
||||||
|
Array.isArray(queries)
|
||||||
|
)
|
||||||
|
throw new ExecutionPlanError("GRAPH_PROGRAM_MISMATCH", "graph", "Expected interpreter program records");
|
||||||
|
const input = kernel as Record<string, unknown>,
|
||||||
|
queryInput = queries as Record<string, unknown>;
|
||||||
|
const { plan: key, ...program } = input;
|
||||||
|
if (
|
||||||
|
input.codec !== "camino-kernel-program-v1" ||
|
||||||
|
queryInput.codec !== "camino-query-program-v1" ||
|
||||||
|
queryInput.plan !== key ||
|
||||||
|
key !== hexDigest({ versions: plan.definition.versions, program })
|
||||||
|
)
|
||||||
|
throw new ExecutionPlanError("GRAPH_PROGRAM_MISMATCH", "graph", "Interpreter inputs do not match execution key");
|
||||||
|
return {
|
||||||
|
codec: "camino-graph-execution-v1",
|
||||||
|
versions: plan.definition.versions,
|
||||||
|
kernel,
|
||||||
|
queries,
|
||||||
|
// Include every selected implementation, including acceptance/remote code
|
||||||
|
// and the browser dependency closure, not merely the public export names.
|
||||||
|
packages: installation.packages.map((pkg) => ({
|
||||||
|
revision: pkg.revision,
|
||||||
|
service: pkg.service ?? null,
|
||||||
|
module: pkg.module ?? null,
|
||||||
|
browser: pkg.browserImplementation ?? null,
|
||||||
|
})),
|
||||||
|
controllers: plan.definition.packages.map((pkg) => ({
|
||||||
|
revision: pkg.revision,
|
||||||
|
controllers: pkg.controllers ?? [],
|
||||||
|
})),
|
||||||
|
shared: {
|
||||||
|
core: installation.coreURL ?? null,
|
||||||
|
engine: installation.engineURL ?? null,
|
||||||
|
components: [
|
||||||
|
...new Map(installation.components.flatMap((component) => Object.entries(component.sharedModules))).entries(),
|
||||||
|
].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export * from "./plan.js";
|
||||||
|
export * from "./native-backends.js";
|
||||||
|
export * from "./artifacts.js";
|
||||||
|
export * from "./ports.js";
|
||||||
|
export * from "./files.js";
|
||||||
|
export * from "./installation.js";
|
||||||
|
export * from "./client-contracts.js";
|
||||||
|
export * from "./execution-groups.js";
|
||||||
|
|
||||||
|
export * from "./receipts.js";
|
||||||
|
|
||||||
|
export { generatePortableTypeScriptBindings, generateRemoteTypeScriptBindings } from "../bindings/portable.js";
|
||||||
|
|
||||||
|
export * from "./kernel.js";
|
||||||
|
export * from "./projections.js";
|
||||||
|
export * from "./queries.js";
|
||||||
|
export * from "./query-document.js";
|
||||||
|
export { generatePortableClientBindings } from "../bindings/portable-client.js";
|
||||||
|
export * from "./candidate.js";
|
||||||
|
export * from "./metadata.js";
|
||||||
|
|
||||||
|
export { reviewSemanticMajors } from "../capability-model/evolution.js";
|
||||||
|
|
||||||
|
export * from "./public-reflection.js";
|
||||||
|
|
||||||
|
export * from "./graph-execution.js";
|
||||||
|
|
||||||
|
export * from "./build-contract.js";
|
||||||
|
export * from "./placement.js";
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { compileKernelProgram } from "./kernel.js";
|
||||||
|
import { hexDigest, type ExecutionPlan } from "./plan.js";
|
||||||
|
import type { PublicQueryContract } from "./client-contracts.js";
|
||||||
|
import { portableValueSchema } from "../bindings/portable.js";
|
||||||
|
const scalar = (name: string) => ({ kind: "scalar", name });
|
||||||
|
const record = (fields: Record<string, unknown>) => ({ kind: "record", fields });
|
||||||
|
const optional = (value: unknown) => ({ kind: "optional", value });
|
||||||
|
const selection = (name: string, extra: Record<string, unknown> = {}) => ({
|
||||||
|
name,
|
||||||
|
key: name,
|
||||||
|
arguments: {},
|
||||||
|
conditions: [],
|
||||||
|
selection: [],
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
const variable = (name: string) => ({ kind: "variable", name });
|
||||||
|
const meta = () => selection("_camino", { selection: [selection("ref")] });
|
||||||
|
export const inspectionPackage = "org.quixos.inspection/1";
|
||||||
|
export interface InspectionTemplate {
|
||||||
|
interface: string;
|
||||||
|
members: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
flag: string;
|
||||||
|
lane: "local" | "server";
|
||||||
|
many: boolean;
|
||||||
|
relationship: boolean;
|
||||||
|
}[];
|
||||||
|
inputs?: PublicQueryContract;
|
||||||
|
view?: PublicQueryContract;
|
||||||
|
server?: PublicQueryContract;
|
||||||
|
completeInputs?: PublicQueryContract;
|
||||||
|
completeView?: PublicQueryContract;
|
||||||
|
}
|
||||||
|
/** A finite installed template per interface/lane, not a per-client query compiler.
|
||||||
|
* Flags select public cache boundaries; normal scope identity bounds specialization. */
|
||||||
|
export interface InspectionKernel {
|
||||||
|
operations: {
|
||||||
|
key: string;
|
||||||
|
atom: string;
|
||||||
|
interface: string;
|
||||||
|
operation: string;
|
||||||
|
effect: string;
|
||||||
|
targets: string[];
|
||||||
|
unsupported: string | null;
|
||||||
|
output: unknown;
|
||||||
|
binding: { kind: string };
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
export function compileInspectionTemplates(
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
kernel: InspectionKernel = compileKernelProgram(plan) as InspectionKernel,
|
||||||
|
) {
|
||||||
|
const queries: unknown[] = [],
|
||||||
|
contracts: PublicQueryContract[] = [],
|
||||||
|
templates: InspectionTemplate[] = [];
|
||||||
|
for (const iface of plan.definition.workspace.interfaceImports) {
|
||||||
|
if (iface.template) continue;
|
||||||
|
const atoms = plan.definition.workspace.conformances
|
||||||
|
.filter((c) => c.interfaceRevisionId === iface.revisionId)
|
||||||
|
.map((c) => c.atomId)
|
||||||
|
.sort();
|
||||||
|
if (!atoms.length) continue;
|
||||||
|
const members = iface.members
|
||||||
|
.filter((member) => (member.kind === "value" || member.kind === "relationship") && member.queryRead)
|
||||||
|
.sort((a, b) => (a.id < b.id ? -1 : 1));
|
||||||
|
if (!members.length || members.length > 128) continue;
|
||||||
|
const template: InspectionTemplate = { interface: iface.revisionId, members: [] };
|
||||||
|
for (const mode of ["local", "complete", "server"] as const) {
|
||||||
|
const lane = mode === "server" ? "server" : "local";
|
||||||
|
const complete = mode === "complete";
|
||||||
|
const fields: unknown[] = [],
|
||||||
|
selections: unknown[] = [],
|
||||||
|
inputFields: Record<string, unknown> = {},
|
||||||
|
outputFields: Record<string, unknown> = {};
|
||||||
|
for (const [index, member] of members.entries()) {
|
||||||
|
if (member.kind !== "value" && member.kind !== "relationship") continue;
|
||||||
|
const getter = member.operations.find((op) => op.displayName === (member.kind === "value" ? "get" : "resolve"));
|
||||||
|
if (!getter) continue;
|
||||||
|
const providers = atoms.map((atom) =>
|
||||||
|
kernel.operations.find(
|
||||||
|
(op) => op.atom === atom && op.interface === iface.revisionId && op.operation === getter.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (providers.some((op) => !op || op.unsupported || !["snapshot", "remote"].includes(op.effect))) continue;
|
||||||
|
const portable = providers.every(
|
||||||
|
(op) => op!.effect === "snapshot" && op!.targets.includes("target:org.quixos.execution.javascript@1"),
|
||||||
|
);
|
||||||
|
if (lane === "local" && !portable) continue;
|
||||||
|
const key = "m" + index,
|
||||||
|
flag = "s" + index;
|
||||||
|
const many = member.kind === "relationship" && ["many", "many-unique"].includes(member.cardinality);
|
||||||
|
// Only native edges promise the relationship traversal used by the interpreter.
|
||||||
|
if (member.kind === "relationship" && providers.some((op) => op!.binding.kind !== "edge")) continue;
|
||||||
|
if (!complete && (lane === "local" || !portable))
|
||||||
|
template.members.push({
|
||||||
|
id: member.id,
|
||||||
|
name: member.displayName,
|
||||||
|
key,
|
||||||
|
flag,
|
||||||
|
lane,
|
||||||
|
many,
|
||||||
|
relationship: member.kind === "relationship",
|
||||||
|
});
|
||||||
|
inputFields[flag] = scalar("bool");
|
||||||
|
const field = selection(member.displayName, {
|
||||||
|
key,
|
||||||
|
interfaceRevisionId: iface.revisionId,
|
||||||
|
memberId: member.id,
|
||||||
|
conditions: [{ include: true, value: variable(flag) }],
|
||||||
|
});
|
||||||
|
let output = portableValueSchema(getter.outputType);
|
||||||
|
if (member.kind === "relationship") {
|
||||||
|
const expectation =
|
||||||
|
member.target.kind === "atom"
|
||||||
|
? { kind: "atom", id: member.target.atomId }
|
||||||
|
: { kind: "interface", id: member.target.interfaceRevisionId };
|
||||||
|
const node = record({ _camino: record({ ref: { kind: "object-ref", expectation } }) });
|
||||||
|
if (member.target.kind === "interface")
|
||||||
|
Object.assign(field, { targetInterfaceRevisionId: member.target.interfaceRevisionId });
|
||||||
|
if (many) {
|
||||||
|
const bound = key + (complete ? "All" : "First");
|
||||||
|
inputFields[bound] = scalar("int32");
|
||||||
|
if (!complete) inputFields[key + "After"] = optional(scalar("string"));
|
||||||
|
const keyFields = { key: scalar("string"), ...(member.keyType ? { mapKey: scalar(member.keyType) } : {}) };
|
||||||
|
const keySelections = [selection("key"), ...(member.keyType ? [selection("mapKey")] : [])];
|
||||||
|
Object.assign(field, {
|
||||||
|
arguments: complete
|
||||||
|
? { all: variable(bound) }
|
||||||
|
: { first: variable(bound), after: variable(key + "After") },
|
||||||
|
selection: [
|
||||||
|
selection("entries", { selection: [...keySelections, selection("node", { selection: [meta()] })] }),
|
||||||
|
...(complete
|
||||||
|
? []
|
||||||
|
: [selection("pageInfo", { selection: [selection("hasNextPage"), selection("endCursor")] })]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
output = record({
|
||||||
|
entries: { kind: "list", value: record({ ...keyFields, node }) },
|
||||||
|
...(complete
|
||||||
|
? {}
|
||||||
|
: { pageInfo: record({ hasNextPage: scalar("bool"), endCursor: optional(scalar("string")) }) }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.assign(field, { selection: [meta()] });
|
||||||
|
output = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outputFields[key] = optional(output);
|
||||||
|
selections.push(field);
|
||||||
|
for (const op of providers)
|
||||||
|
fields.push({
|
||||||
|
atom: op!.atom,
|
||||||
|
interface: iface.revisionId,
|
||||||
|
member: member.id,
|
||||||
|
name: member.displayName,
|
||||||
|
operation: op!.key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!selections.length) continue;
|
||||||
|
const root = { kind: "object" as const, interface: iface.revisionId };
|
||||||
|
const input = record(inputFields),
|
||||||
|
output = record({ root: record(outputFields) });
|
||||||
|
const base = {
|
||||||
|
root,
|
||||||
|
atoms,
|
||||||
|
variables: input,
|
||||||
|
defaults: {},
|
||||||
|
selection: [selection("root", { selection: selections })],
|
||||||
|
fields,
|
||||||
|
admittedOperations: [],
|
||||||
|
budgets: {
|
||||||
|
rows: 512,
|
||||||
|
depth: 8,
|
||||||
|
resultBytes: 8 * 1024 * 1024,
|
||||||
|
candidates: 1000,
|
||||||
|
rpcCalls: 128,
|
||||||
|
concurrency: 8,
|
||||||
|
deadlineMs: 10000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (const kind of lane === "local" ? (["inputs", "view"] as const) : (["server"] as const)) {
|
||||||
|
const templateKey = complete ? (kind === "inputs" ? "completeInputs" : "completeView") : kind;
|
||||||
|
const query = JSON.stringify([iface.revisionId, templateKey]);
|
||||||
|
const sourceQuery = JSON.stringify([iface.revisionId, complete ? "completeInputs" : "inputs"]);
|
||||||
|
const program = {
|
||||||
|
...base,
|
||||||
|
id: JSON.stringify([inspectionPackage, query]),
|
||||||
|
placement: kind === "inputs" ? "replica" : kind === "view" ? "local" : "remote",
|
||||||
|
source: kind === "view" ? JSON.stringify([inspectionPackage, sourceQuery]) : null,
|
||||||
|
};
|
||||||
|
const contract: PublicQueryContract = {
|
||||||
|
package: inspectionPackage,
|
||||||
|
query,
|
||||||
|
definitionDigest: hexDigest(program),
|
||||||
|
root,
|
||||||
|
placement: kind === "inputs" ? "replica" : kind === "view" ? "local" : "server",
|
||||||
|
...(kind === "view" ? { sourceQuery } : {}),
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
};
|
||||||
|
queries.push(program);
|
||||||
|
contracts.push(contract);
|
||||||
|
template[templateKey] = contract;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (template.members.length) templates.push(template);
|
||||||
|
}
|
||||||
|
return { queries, contracts, templates };
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import { executionPlanDigestAsync, ExecutionPlanError, type ExecutionPlan } from "./plan.js";
|
||||||
|
import { compileInspectionTemplates, type InspectionTemplate, type InspectionKernel } from "./inspection.js";
|
||||||
|
import path from "node:path";
|
||||||
|
import { loadCheckedArtifactWorld } from "./receipts.js";
|
||||||
|
import { contentArtifactUrl, loadArtifactWorld, type LoadedArtifactWorld } from "./files.js";
|
||||||
|
import { selectArtifactImplementation, type ArtifactImplementation, type ComponentArtifact } from "./artifacts.js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
publicOperationContract,
|
||||||
|
publicQueryContract,
|
||||||
|
type PublicOperationContract,
|
||||||
|
type PublicQueryContract,
|
||||||
|
} from "./client-contracts.js";
|
||||||
|
import { resultInterfaces } from "./projections.js";
|
||||||
|
import type { CandidateMetadataScope } from "./metadata.js";
|
||||||
|
|
||||||
|
/** Activation is assigned by graph authority, never by a package artifact. */
|
||||||
|
export interface ActiveExecutionIdentity {
|
||||||
|
readonly workspace: string;
|
||||||
|
readonly world: string;
|
||||||
|
readonly plan: string;
|
||||||
|
readonly epoch: string;
|
||||||
|
}
|
||||||
|
/** Host-owned execution selection. The lifecycle manager fills its checked inputs. */
|
||||||
|
export interface ExecutionGroupIdentity {
|
||||||
|
readonly logicalId: string;
|
||||||
|
readonly executionDigest: string;
|
||||||
|
}
|
||||||
|
export interface ClientContractCatalog {
|
||||||
|
readonly inspections: readonly InspectionTemplate[];
|
||||||
|
readonly resultInterfaces: Readonly<Record<string, readonly string[]>>;
|
||||||
|
readonly operations: readonly PublicOperationContract[];
|
||||||
|
readonly queries: readonly PublicQueryContract[];
|
||||||
|
readonly components: readonly InstalledComponent[];
|
||||||
|
readonly presentations: readonly { id: string; subject: string; packageRevision: string; component: string }[];
|
||||||
|
readonly moduleBindings: readonly {
|
||||||
|
atom: string;
|
||||||
|
interface: string;
|
||||||
|
member: string;
|
||||||
|
packageRevision: string;
|
||||||
|
id: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
/** Explicit projection prevents accidental spreading of filesystem roots or private bindings. */
|
||||||
|
export async function clientContractCatalog(
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
installation: ExecutionInstallation,
|
||||||
|
requestedPackages: readonly string[],
|
||||||
|
checkedKernel?: InspectionKernel,
|
||||||
|
): Promise<ClientContractCatalog> {
|
||||||
|
if (installation.plan !== plan.digest || (await executionPlanDigestAsync(plan)) !== plan.digest)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"CATALOG_PLAN_MISMATCH",
|
||||||
|
plan.digest,
|
||||||
|
"Installation and public catalog must use the same checked plan",
|
||||||
|
);
|
||||||
|
const packages = requestedPackages.map((revision) => {
|
||||||
|
const pkg = plan.definition.workspace.packageImports.find((p) => p.revisionId === revision);
|
||||||
|
if (!pkg)
|
||||||
|
throw new ExecutionPlanError("UNINSTALLED_PACKAGE", revision, "Requested catalog package is not installed");
|
||||||
|
return pkg;
|
||||||
|
});
|
||||||
|
// Interface metadata contains no authority or private implementation configuration.
|
||||||
|
// A later admission optimization can narrow this set without changing operation identities.
|
||||||
|
const operations = plan.definition.workspace.interfaceImports
|
||||||
|
.filter((i) => !i.template)
|
||||||
|
.flatMap((iface) =>
|
||||||
|
iface.members.flatMap((member) =>
|
||||||
|
member.operations
|
||||||
|
.filter((o) => o.mode === "call")
|
||||||
|
.map((operation) => publicOperationContract(iface, operation)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const inspection = compileInspectionTemplates(plan, checkedKernel);
|
||||||
|
return {
|
||||||
|
inspections: inspection.templates,
|
||||||
|
resultInterfaces: Object.fromEntries(
|
||||||
|
plan.definition.operations.map((operation) => [operation.key, resultInterfaces(plan, operation.key)]),
|
||||||
|
),
|
||||||
|
operations,
|
||||||
|
queries: [
|
||||||
|
...inspection.contracts,
|
||||||
|
...packages.flatMap((pkg) => (pkg.checkedQueries ?? []).map((q) => publicQueryContract(pkg, q.declaration.id))),
|
||||||
|
],
|
||||||
|
components: installation.components.filter((c) => requestedPackages.includes(c.packageRevision)),
|
||||||
|
moduleBindings: plan.definition.workspace.conformances.flatMap((c) =>
|
||||||
|
(c.moduleBindings ?? [])
|
||||||
|
.filter((b) => requestedPackages.includes(b.packageRevisionId))
|
||||||
|
.map((b) => ({
|
||||||
|
atom: c.atomId,
|
||||||
|
interface: c.interfaceRevisionId,
|
||||||
|
member: b.memberId,
|
||||||
|
packageRevision: b.packageRevisionId,
|
||||||
|
id: b.moduleId,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
presentations: (plan.definition.presentations ?? [])
|
||||||
|
.filter((p) => requestedPackages.includes(p.packageRevisionId))
|
||||||
|
.map((p) => ({ id: p.id, subject: p.subject, packageRevision: p.packageRevisionId, component: p.componentId })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Candidate-owned compiler output, never graph state or authorization. Compile
|
||||||
|
* once, then project each requested package set without hashing/recompiling the
|
||||||
|
* plan. Reserve bounded metadata before compilation; disposal releases both the
|
||||||
|
* catalog and its charge. Callers must supply the candidate's checked inputs. */
|
||||||
|
export function createClientContractCatalog(
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
installation: ExecutionInstallation,
|
||||||
|
metadata: CandidateMetadataScope,
|
||||||
|
checkedKernel?: InspectionKernel,
|
||||||
|
) {
|
||||||
|
const revisions = plan.definition.workspace.packageImports.map((pkg) => pkg.revisionId);
|
||||||
|
const installed = new Set<string>(revisions);
|
||||||
|
let catalog: ClientContractCatalog | undefined;
|
||||||
|
let preparing: Promise<void> | undefined;
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
let closed = false;
|
||||||
|
const freeze = (value: unknown, seen = new WeakSet<object>()) => {
|
||||||
|
if (!value || typeof value !== "object" || seen.has(value)) return;
|
||||||
|
seen.add(value);
|
||||||
|
for (const child of Object.values(value)) freeze(child, seen);
|
||||||
|
Object.freeze(value);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
async select(requestedPackages: readonly string[]): Promise<ClientContractCatalog> {
|
||||||
|
if (closed) throw Error("CANDIDATE_METADATA_CLOSED");
|
||||||
|
for (const revision of requestedPackages)
|
||||||
|
if (!installed.has(revision))
|
||||||
|
throw new ExecutionPlanError("UNINSTALLED_PACKAGE", revision, "Requested catalog package is not installed");
|
||||||
|
if (!catalog) {
|
||||||
|
// Like other candidate admission charges, this is an allocation estimate,
|
||||||
|
// not a V8 heap limit. The process/cgroup ceiling bounds transient work.
|
||||||
|
preparing ??= (async () => {
|
||||||
|
const reservation = metadata.reserve(32 * 1024 * 1024);
|
||||||
|
try {
|
||||||
|
const compiled = await clientContractCatalog(plan, installation, revisions, checkedKernel);
|
||||||
|
if (closed) throw Error("CANDIDATE_METADATA_CLOSED");
|
||||||
|
const bytes = Buffer.byteLength(JSON.stringify(compiled));
|
||||||
|
if (bytes > 8 * 1024 * 1024) throw Error("CANDIDATE_CATALOG_LIMIT");
|
||||||
|
freeze(compiled);
|
||||||
|
// No await between reservations: replacing the transient upper bound
|
||||||
|
// with a smaller retained estimate cannot race another admission.
|
||||||
|
reservation();
|
||||||
|
release = metadata.reserve(Math.max(64 * 1024, bytes * 4));
|
||||||
|
catalog = compiled;
|
||||||
|
} catch (error) {
|
||||||
|
reservation();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
preparing = undefined;
|
||||||
|
});
|
||||||
|
await preparing;
|
||||||
|
}
|
||||||
|
if (closed || !catalog) throw Error("CANDIDATE_METADATA_CLOSED");
|
||||||
|
const requested = new Set(requestedPackages);
|
||||||
|
return {
|
||||||
|
inspections: catalog.inspections,
|
||||||
|
resultInterfaces: catalog.resultInterfaces,
|
||||||
|
operations: catalog.operations,
|
||||||
|
queries: [
|
||||||
|
...catalog.queries.filter((query) => !installed.has(query.package)),
|
||||||
|
...requestedPackages.flatMap((revision) => catalog!.queries.filter((query) => query.package === revision)),
|
||||||
|
],
|
||||||
|
components: catalog.components.filter((component) => requested.has(component.packageRevision)),
|
||||||
|
moduleBindings: catalog.moduleBindings.filter((binding) => requested.has(binding.packageRevision)),
|
||||||
|
presentations: catalog.presentations.filter((presentation) => requested.has(presentation.packageRevision)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
closed = true;
|
||||||
|
catalog = undefined;
|
||||||
|
release?.();
|
||||||
|
release = undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageInstallationSelection {
|
||||||
|
readonly browser?: { readonly id?: string };
|
||||||
|
readonly service?: { readonly id?: string };
|
||||||
|
/** Including a module is optional; executing it still requires host trust policy. */
|
||||||
|
readonly module?: { readonly id?: string };
|
||||||
|
}
|
||||||
|
export interface ExecutionInstallation {
|
||||||
|
readonly codec: "camino-execution-installation-v1";
|
||||||
|
readonly plan: string;
|
||||||
|
readonly world: string;
|
||||||
|
readonly packages: readonly {
|
||||||
|
readonly revision: string;
|
||||||
|
readonly root: string;
|
||||||
|
readonly service?: ArtifactImplementation;
|
||||||
|
readonly module?: ArtifactImplementation;
|
||||||
|
/** Checked candidate only; the qualified host verifies its import surface and SDK. */
|
||||||
|
readonly restricted?: ArtifactImplementation;
|
||||||
|
readonly browserImplementation?: ArtifactImplementation;
|
||||||
|
readonly browserURL?: string;
|
||||||
|
readonly browserRegistry?: string;
|
||||||
|
}[];
|
||||||
|
readonly routes: Readonly<Record<string, { readonly path: string; readonly mediaType: string }>>;
|
||||||
|
readonly coreURL?: string;
|
||||||
|
readonly engineURL?: string;
|
||||||
|
readonly components: readonly InstalledComponent[];
|
||||||
|
}
|
||||||
|
/** Safe to expose in a presentation catalog: no source paths, state slots, or host configuration. */
|
||||||
|
export interface InstalledComponent {
|
||||||
|
readonly role: {
|
||||||
|
readonly interface: string;
|
||||||
|
readonly member: string;
|
||||||
|
readonly subject: string;
|
||||||
|
readonly contract: string;
|
||||||
|
readonly typeArguments?: readonly unknown[];
|
||||||
|
readonly requiredInterfaces?: readonly string[];
|
||||||
|
};
|
||||||
|
readonly packageRevision: string;
|
||||||
|
readonly id: string;
|
||||||
|
readonly subject: string;
|
||||||
|
readonly url: string;
|
||||||
|
readonly exportName: string;
|
||||||
|
readonly styles: readonly string[];
|
||||||
|
readonly compatibility: ComponentArtifact["compatibility"];
|
||||||
|
/** Owned and interpreted by the React artifact consumer. */
|
||||||
|
readonly preparation?: unknown;
|
||||||
|
readonly sharedModules: Readonly<Record<keyof ComponentArtifact["sharedModules"], string>>;
|
||||||
|
}
|
||||||
|
/** Build-time validation uses the compiler selected by the Nix derivation. */
|
||||||
|
export async function checkExecutionInstallation(
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
roots: Readonly<Record<string, string>>,
|
||||||
|
generator: string,
|
||||||
|
selections: Readonly<Record<string, PackageInstallationSelection>> = {},
|
||||||
|
): Promise<ExecutionInstallation> {
|
||||||
|
const loaded = await loadCheckedArtifactWorld(plan, roots, generator);
|
||||||
|
return assembleExecutionInstallation(plan, roots, loaded, selections);
|
||||||
|
}
|
||||||
|
/** Load immutable artifacts without regenerating bindings with the host compiler.
|
||||||
|
* Format, coverage, content and runtime ABI checks still apply. This grants no
|
||||||
|
* authority to activate a world or run optional modules in process. */
|
||||||
|
export async function loadExecutionInstallation(
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
roots: Readonly<Record<string, string>>,
|
||||||
|
selections: Readonly<Record<string, PackageInstallationSelection>> = {},
|
||||||
|
metadata?: CandidateMetadataScope,
|
||||||
|
): Promise<ExecutionInstallation> {
|
||||||
|
return assembleExecutionInstallation(plan, roots, await loadArtifactWorld(plan, roots, metadata), selections);
|
||||||
|
}
|
||||||
|
function assembleExecutionInstallation(
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
roots: Readonly<Record<string, string>>,
|
||||||
|
loaded: LoadedArtifactWorld,
|
||||||
|
selections: Readonly<Record<string, PackageInstallationSelection>>,
|
||||||
|
): ExecutionInstallation {
|
||||||
|
const fail = (where: string, message: string): never => {
|
||||||
|
throw new ExecutionPlanError("INSTALLATION_INVALID", where, message);
|
||||||
|
};
|
||||||
|
for (const revision of Object.keys(selections))
|
||||||
|
if (!loaded.manifests.some((manifest) => manifest.packageRevisionId === revision))
|
||||||
|
fail(revision, "Selection names a package outside this checked world");
|
||||||
|
const routes: Record<string, { path: string; mediaType: string }> = Object.create(null);
|
||||||
|
const packages: ExecutionInstallation["packages"][number][] = [];
|
||||||
|
const components: InstalledComponent[] = [];
|
||||||
|
const sharedComponentModules = new Map<string, string>();
|
||||||
|
let coreURL: string | undefined, engineURL: string | undefined;
|
||||||
|
for (const manifest of loaded.manifests) {
|
||||||
|
const revision = manifest.packageRevisionId;
|
||||||
|
const root = roots[revision]!;
|
||||||
|
const selection = selections[revision] ?? {};
|
||||||
|
const exports = plan.definition.packages.find((pkg) => pkg.revision === revision)!.exports;
|
||||||
|
const service =
|
||||||
|
exports.length || selection.service
|
||||||
|
? selectArtifactImplementation(manifest, {
|
||||||
|
...selection.service,
|
||||||
|
target: "server",
|
||||||
|
compatibility: "quixos-service-v1",
|
||||||
|
exports: exports.map((entry) => entry.id),
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
for (const component of manifest.components ?? []) {
|
||||||
|
const sharedModules = Object.fromEntries(
|
||||||
|
Object.entries(component.sharedModules).map(([name, filePath]) => {
|
||||||
|
const file = component.files.find((file) => file.path === filePath)!;
|
||||||
|
const url = contentArtifactUrl(file),
|
||||||
|
previous = sharedComponentModules.get(name);
|
||||||
|
if (previous && previous !== url) fail(component.id, `Components disagree on shared module ${name}`);
|
||||||
|
sharedComponentModules.set(name, url);
|
||||||
|
return [name, url];
|
||||||
|
}),
|
||||||
|
) as InstalledComponent["sharedModules"];
|
||||||
|
for (const file of component.files) {
|
||||||
|
const url = contentArtifactUrl(file);
|
||||||
|
if (routes[url] && routes[url].mediaType !== file.mediaType) fail(url, "Conflicting artifact media types");
|
||||||
|
routes[url] ??= { path: path.join(root, file.path), mediaType: file.mediaType };
|
||||||
|
}
|
||||||
|
components.push({
|
||||||
|
...(() => {
|
||||||
|
const pkg = plan.definition.workspace.packageImports.find((entry) => entry.revisionId === revision);
|
||||||
|
const artifact = pkg?.buildArtifacts?.["org.quixos.react.preparation/1"] as
|
||||||
|
| { modules: { module: string }[] }
|
||||||
|
| undefined;
|
||||||
|
const preparation = artifact?.modules.find((entry) => entry.module === component.id);
|
||||||
|
return preparation ? { preparation } : {};
|
||||||
|
})(),
|
||||||
|
role: (() => {
|
||||||
|
const module = plan.definition.packages
|
||||||
|
.find((p) => p.revision === revision)!
|
||||||
|
.modules!.find((m) => m.id === component.id)!;
|
||||||
|
return {
|
||||||
|
interface: module.interfaceRevisionId,
|
||||||
|
member: module.memberId,
|
||||||
|
subject: component.subject,
|
||||||
|
contract: module.contract,
|
||||||
|
...(module.typeArguments ? { typeArguments: module.typeArguments } : {}),
|
||||||
|
...(module.requiredInterfaces ? { requiredInterfaces: module.requiredInterfaces } : {}),
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
packageRevision: revision,
|
||||||
|
id: component.id,
|
||||||
|
subject: component.subject,
|
||||||
|
compatibility: component.compatibility,
|
||||||
|
exportName: component.exportName,
|
||||||
|
styles: component.files.filter((file) => file.mediaType === "text/css").map(contentArtifactUrl),
|
||||||
|
sharedModules,
|
||||||
|
url: contentArtifactUrl(component.files.find((file) => file.path === component.path)!),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const module = selection.module
|
||||||
|
? selectArtifactImplementation(manifest, {
|
||||||
|
...selection.module,
|
||||||
|
target: "server",
|
||||||
|
compatibility: "quixos-js-module-v1",
|
||||||
|
exports: exports.map((entry) => entry.id),
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
const restrictedCandidates = manifest.implementations.filter(
|
||||||
|
(entry) => entry.compatibility === "quixos-restricted-module-v1",
|
||||||
|
);
|
||||||
|
if (restrictedCandidates.length > 1) fail(revision, "Ambiguous restricted module candidates");
|
||||||
|
const restricted = restrictedCandidates[0];
|
||||||
|
const browserExports = exports.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.execution.kind !== "remote" &&
|
||||||
|
entry.execution.targets.includes("target:org.quixos.execution.javascript@1"),
|
||||||
|
);
|
||||||
|
if (!browserExports.length) {
|
||||||
|
if (selection.browser) fail(revision, "Package has no browser exports to select");
|
||||||
|
packages.push({
|
||||||
|
revision,
|
||||||
|
root,
|
||||||
|
...(service ? { service } : {}),
|
||||||
|
...(module ? { module } : {}),
|
||||||
|
...(restricted ? { restricted } : {}),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const browser = selectArtifactImplementation(manifest, {
|
||||||
|
...selection.browser,
|
||||||
|
target: "target:org.quixos.execution.javascript@1",
|
||||||
|
compatibility: "quixos-js-module-v1",
|
||||||
|
exports: browserExports.map((entry) => entry.id),
|
||||||
|
});
|
||||||
|
const entry = browser.files.find((file) => file.path === browser.path)!;
|
||||||
|
const shared = (name: string) => {
|
||||||
|
const files = browser.files.filter((file) => file.path.endsWith("/" + name));
|
||||||
|
if (files.length !== 1) fail(revision, "Expected one shared " + name);
|
||||||
|
return contentArtifactUrl(files[0]!);
|
||||||
|
};
|
||||||
|
const core = shared("core.mjs"),
|
||||||
|
engine = shared("engine.mjs");
|
||||||
|
if ((coreURL && coreURL !== core) || (engineURL && engineURL !== engine))
|
||||||
|
fail(revision, "Browser packages disagree on the shared runtime identity");
|
||||||
|
coreURL = core;
|
||||||
|
engineURL = engine;
|
||||||
|
for (const file of browser.files) {
|
||||||
|
const url = contentArtifactUrl(file);
|
||||||
|
if (routes[url] && routes[url].mediaType !== file.mediaType) fail(url, "Conflicting artifact media types");
|
||||||
|
routes[url] ??= { path: path.join(root, file.path), mediaType: file.mediaType };
|
||||||
|
}
|
||||||
|
packages.push({
|
||||||
|
revision,
|
||||||
|
root,
|
||||||
|
...(service ? { service } : {}),
|
||||||
|
...(module ? { module } : {}),
|
||||||
|
...(restricted ? { restricted } : {}),
|
||||||
|
browserImplementation: browser,
|
||||||
|
browserURL: contentArtifactUrl(entry),
|
||||||
|
browserRegistry: browser.registryExport,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
codec: "camino-execution-installation-v1",
|
||||||
|
plan: plan.digest,
|
||||||
|
world: loaded.digest,
|
||||||
|
packages,
|
||||||
|
components,
|
||||||
|
routes,
|
||||||
|
...(coreURL ? { coreURL, engineURL } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import { executionPlanDigest, type ExecutionPlan, ExecutionPlanError, hexDigest } from "./plan.js";
|
||||||
|
import { portableReflectionType, portableReflectionValue } from "./reflection.js";
|
||||||
|
import type {
|
||||||
|
ValueType,
|
||||||
|
InitializationField,
|
||||||
|
PersistentAttachment,
|
||||||
|
DependencyBinding,
|
||||||
|
BoundDependency,
|
||||||
|
PackageExport,
|
||||||
|
} from "../capability-model/types.js";
|
||||||
|
|
||||||
|
import { portableValueSchema } from "../bindings/portable.js";
|
||||||
|
import { subscriptionContract } from "../capability-model/journals.js";
|
||||||
|
type PreparedStep = string | null | { case: string; id: string };
|
||||||
|
function preparedInputPaths(type: ValueType, path: PreparedStep[] = []): PreparedStep[][] {
|
||||||
|
if (type.kind === "change") return [path];
|
||||||
|
if (type.kind === "optional") return preparedInputPaths(type.value, path);
|
||||||
|
if (type.kind === "list") return preparedInputPaths(type.value, [...path, null]);
|
||||||
|
if (type.kind === "variant")
|
||||||
|
return Object.entries(type.cases).flatMap(([label, entry]) =>
|
||||||
|
preparedInputPaths(entry.value, [...path, { case: label, id: entry.id }]),
|
||||||
|
);
|
||||||
|
if (type.kind === "record")
|
||||||
|
return Object.entries(type.fields).flatMap(([key, value]) => preparedInputPaths(value, [...path, key]));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
/** Small data-only interpreter input; no compiler, parser, Node module or SQL dependency reaches the browser. */
|
||||||
|
export function compileKernelProgram(plan: ExecutionPlan): unknown {
|
||||||
|
if (executionPlanDigest(plan) !== plan.digest)
|
||||||
|
throw new ExecutionPlanError("INVALID_PLAN", "kernel", "Plan digest mismatch");
|
||||||
|
const workspace = plan.definition.workspace;
|
||||||
|
if (workspace.sharedAttachments.some((attachment) => attachment.kind !== "edge"))
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"UNSUPPORTED_SHARED_STORAGE",
|
||||||
|
"kernel",
|
||||||
|
"Shared scalar storage is not implemented; use conformance-owned state",
|
||||||
|
);
|
||||||
|
const schema = (type: ValueType): unknown | null => {
|
||||||
|
try {
|
||||||
|
return portableValueSchema(type);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const ownerBySlot = new Map<string, string>();
|
||||||
|
const edges: { id: string; owner: string | null; a: unknown; b: unknown }[] = [];
|
||||||
|
const lowerEdge = (storage: Extract<PersistentAttachment, { kind: "edge" }>, owner: string | null) => {
|
||||||
|
const endpoints = storage.endpoints.map((endpoint) => {
|
||||||
|
if (endpoint.ordered || (endpoint.keyType && endpoint.keyType !== "string") || endpoint.retainOther)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"UNSUPPORTED_GRAPH_FEATURE",
|
||||||
|
storage.id,
|
||||||
|
"Ordered relations, non-string keys and automatic retention are not implemented",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
projection: endpoint.projectionId,
|
||||||
|
constraint: {
|
||||||
|
kind: endpoint.constraint.kind,
|
||||||
|
id:
|
||||||
|
endpoint.constraint.kind === "atom" ? endpoint.constraint.atomId : endpoint.constraint.interfaceRevisionId,
|
||||||
|
},
|
||||||
|
cardinality: endpoint.cardinality,
|
||||||
|
...(endpoint.keyType ? { keyType: endpoint.keyType } : {}),
|
||||||
|
...(endpoint.onDelete ? { onDelete: endpoint.onDelete } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
edges.push({ id: storage.id, owner, a: endpoints[0], b: endpoints[1] });
|
||||||
|
};
|
||||||
|
for (const storage of workspace.sharedAttachments) if (storage.kind === "edge") lowerEdge(storage, null);
|
||||||
|
const owners = plan.definition.owners.map((owner) => {
|
||||||
|
const slots: unknown[] = [];
|
||||||
|
for (const storage of owner.storage as PersistentAttachment[]) {
|
||||||
|
if (storage.kind === "state") {
|
||||||
|
const shape = schema(storage.valueType);
|
||||||
|
if (!shape)
|
||||||
|
throw new ExecutionPlanError("UNSUPPORTED_STORAGE_CODEC", storage.id, "Unsupported canonical value type");
|
||||||
|
ownerBySlot.set(storage.id, owner.id);
|
||||||
|
const pinsFor = (payload: ValueType) => {
|
||||||
|
const expectations = new Map<string, { kind: "atom" | "interface"; id: string }>();
|
||||||
|
const walk = (type: ValueType): void => {
|
||||||
|
if (type.kind === "object-ref") {
|
||||||
|
const expectation = {
|
||||||
|
kind: type.expectation.kind,
|
||||||
|
id: type.expectation.kind === "atom" ? type.expectation.atomId : type.expectation.interfaceRevisionId,
|
||||||
|
};
|
||||||
|
expectations.set(JSON.stringify(expectation), expectation);
|
||||||
|
} else if (type.kind === "list" || type.kind === "optional") walk(type.value);
|
||||||
|
else if (type.kind === "record") Object.values(type.fields).forEach(walk);
|
||||||
|
else if (type.kind === "variant") Object.values(type.cases).forEach((entry) => walk(entry.value));
|
||||||
|
};
|
||||||
|
walk(payload);
|
||||||
|
const pins = [...expectations.values()].map((expectation) => {
|
||||||
|
const id = `journal-pin:${hexDigest([storage.id, expectation])}`;
|
||||||
|
const projection = `${id}:source`;
|
||||||
|
edges.push({
|
||||||
|
id,
|
||||||
|
owner: owner.id,
|
||||||
|
a: {
|
||||||
|
projection,
|
||||||
|
constraint: { kind: "atom", id: owner.atom },
|
||||||
|
cardinality: "many",
|
||||||
|
keyType: "string",
|
||||||
|
onDelete: "detach",
|
||||||
|
},
|
||||||
|
b: { projection: `${id}:target`, constraint: expectation, cardinality: "many", onDelete: "restrict" },
|
||||||
|
});
|
||||||
|
return { edge: id, projection };
|
||||||
|
});
|
||||||
|
return pins;
|
||||||
|
};
|
||||||
|
let journal: unknown, subscription: unknown;
|
||||||
|
if (storage.storagePolicy.kind === "journal") {
|
||||||
|
journal = { limits: storage.storagePolicy.limits, pins: pinsFor(storage.valueType) };
|
||||||
|
}
|
||||||
|
if (storage.storagePolicy.kind === "subscription") {
|
||||||
|
const bindings = plan.definition.operations.filter(
|
||||||
|
(operation) =>
|
||||||
|
operation.atomId === owner.atom &&
|
||||||
|
operation.binding.kind === "subscription" &&
|
||||||
|
operation.binding.slotId === storage.id &&
|
||||||
|
operation.binding.primitive === "attach",
|
||||||
|
);
|
||||||
|
if (bindings.length !== 1)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"SUBSCRIPTION_ATTACHMENT_REQUIRED",
|
||||||
|
storage.id,
|
||||||
|
"Native subscription storage requires one checked attachment operation",
|
||||||
|
);
|
||||||
|
const binding = bindings[0]!;
|
||||||
|
const operation = workspace.interfaceImports
|
||||||
|
.find((iface) => iface.revisionId === binding.interfaceRevisionId)!
|
||||||
|
.members.flatMap((member) => member.operations)
|
||||||
|
.find((operation) => operation.id === binding.operationId)!;
|
||||||
|
const contract = subscriptionContract(
|
||||||
|
storage.storagePolicy.payload,
|
||||||
|
storage.valueType,
|
||||||
|
operation,
|
||||||
|
workspace.interfaceImports,
|
||||||
|
);
|
||||||
|
const expectation = (value: typeof contract.source) => ({
|
||||||
|
kind: value.kind,
|
||||||
|
id: value.kind === "atom" ? value.atomId : value.interfaceRevisionId,
|
||||||
|
});
|
||||||
|
const source = expectation(contract.source),
|
||||||
|
handler = expectation(contract.handler);
|
||||||
|
const configuration: ValueType = {
|
||||||
|
kind: "record",
|
||||||
|
fields: {
|
||||||
|
source: { kind: "object-ref", expectation: contract.source },
|
||||||
|
handler: { kind: "object-ref", expectation: contract.handler },
|
||||||
|
parameters: storage.valueType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const edge = `subscription-active:${hexDigest(storage.id)}`,
|
||||||
|
projection = `${edge}:subscription`;
|
||||||
|
edges.push({
|
||||||
|
id: edge,
|
||||||
|
owner: owner.id,
|
||||||
|
a: { projection, constraint: { kind: "atom", id: owner.atom }, cardinality: "many", onDelete: "restrict" },
|
||||||
|
b: { projection: `${edge}:stream`, constraint: source, cardinality: "many", onDelete: "restrict" },
|
||||||
|
});
|
||||||
|
subscription = {
|
||||||
|
payload: schema(storage.storagePolicy.payload),
|
||||||
|
source,
|
||||||
|
handler,
|
||||||
|
operation: contract.operation,
|
||||||
|
pins: pinsFor(configuration),
|
||||||
|
active: { edge, projection },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
slots.push({
|
||||||
|
id: storage.id,
|
||||||
|
schema: shape,
|
||||||
|
policy: storage.storagePolicy.kind === "crdt-document" ? "crdt" : storage.storagePolicy.kind,
|
||||||
|
...(journal ? { journal } : {}),
|
||||||
|
...(subscription ? { subscription } : {}),
|
||||||
|
...(Object.hasOwn(storage, "defaultValue") ? { defaultLiteral: storage.defaultValue } : {}),
|
||||||
|
});
|
||||||
|
} else lowerEdge(storage, owner.id);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: owner.id,
|
||||||
|
atom: owner.atom,
|
||||||
|
interface: owner.interface,
|
||||||
|
slots,
|
||||||
|
initialize: (owner.initialization as InitializationField[]).map((field) => ({
|
||||||
|
input: field.input,
|
||||||
|
binding:
|
||||||
|
field.binding.kind === "state"
|
||||||
|
? { kind: "state", slot: field.binding.slotId }
|
||||||
|
: { kind: "edge", edge: field.binding.edgeTypeId, projection: field.binding.projectionId },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const slotOwner = (slot: string) => {
|
||||||
|
const owner = ownerBySlot.get(slot);
|
||||||
|
if (!owner) throw new ExecutionPlanError("MISSING_STORAGE_OWNER", slot, "No checked owner");
|
||||||
|
return owner;
|
||||||
|
};
|
||||||
|
const via = (binding: DependencyBinding) =>
|
||||||
|
"via" in binding && binding.via
|
||||||
|
? { via: { edge: binding.via.edgeTypeId, projection: binding.via.projectionId } }
|
||||||
|
: {};
|
||||||
|
const operations = plan.definition.operations.map((operation) => {
|
||||||
|
const binding = operation.binding;
|
||||||
|
let target: unknown;
|
||||||
|
let unsupported: string | null = null;
|
||||||
|
if (binding.kind === "lifecycle") target = { kind: "lifecycle", primitive: binding.primitive };
|
||||||
|
else if (binding.kind === "journal" || binding.kind === "subscription")
|
||||||
|
target = {
|
||||||
|
kind: binding.kind,
|
||||||
|
owner: slotOwner(binding.slotId),
|
||||||
|
slot: binding.slotId,
|
||||||
|
primitive: binding.primitive,
|
||||||
|
};
|
||||||
|
else if (binding.kind === "state")
|
||||||
|
target = { kind: "state", owner: slotOwner(binding.slotId), slot: binding.slotId, primitive: binding.primitive };
|
||||||
|
else if (binding.kind === "edge")
|
||||||
|
target = {
|
||||||
|
kind: "edge",
|
||||||
|
edge: binding.edgeTypeId,
|
||||||
|
projection: binding.projectionId,
|
||||||
|
primitive: binding.primitive,
|
||||||
|
};
|
||||||
|
else {
|
||||||
|
const pkg = workspace.packageImports.find((p) => p.revisionId === binding.packageRevisionId)!;
|
||||||
|
const entry = pkg.exports.find((e) => e.id === binding.exportId)!;
|
||||||
|
const compilePorts = (entry: PackageExport, dependencies: BoundDependency[]) =>
|
||||||
|
dependencies.map((dependency) => {
|
||||||
|
const requirement = entry.dependencyPorts.find((p) => p.id === dependency.portId)!.requirement;
|
||||||
|
const bound = dependency.binding;
|
||||||
|
const common = { id: dependency.portId, ...via(bound) };
|
||||||
|
if (bound.kind === "reflection" && requirement.kind === "reflection")
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
kind: "reflection",
|
||||||
|
values: Object.fromEntries(
|
||||||
|
bound.selectors.map((selector) => [
|
||||||
|
selector,
|
||||||
|
{
|
||||||
|
schema: portableValueSchema(portableReflectionType(selector)),
|
||||||
|
value: portableReflectionValue(workspace, selector),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if (bound.kind === "state" && requirement.kind === "state")
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
kind: "state",
|
||||||
|
owner: slotOwner(bound.slotId),
|
||||||
|
slot: bound.slotId,
|
||||||
|
primitives: requirement.primitives,
|
||||||
|
};
|
||||||
|
if (bound.kind === "edge" && requirement.kind === "edge")
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
kind: "edge",
|
||||||
|
edge: bound.edgeTypeId,
|
||||||
|
projection: bound.projectionId,
|
||||||
|
primitives: requirement.primitives,
|
||||||
|
};
|
||||||
|
if (bound.kind === "interface" && requirement.kind === "interface") {
|
||||||
|
const iface = workspace.interfaceImports.find((i) => i.revisionId === bound.interfaceRevisionId)!;
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
kind: "interface",
|
||||||
|
interface: bound.interfaceRevisionId,
|
||||||
|
...(!bound.receiver &&
|
||||||
|
!bound.via &&
|
||||||
|
(requirement.operations ?? iface.members.flatMap((m) => m.operations.map((o) => o.id))).every(
|
||||||
|
(id) => iface.members.flatMap((m) => m.operations).find((o) => o.id === id)?.scope === "class",
|
||||||
|
)
|
||||||
|
? { staticAtom: operation.atomId }
|
||||||
|
: {}),
|
||||||
|
...(bound.receiver ? { receiver: bound.receiver } : {}),
|
||||||
|
operations: requirement.operations ?? iface.members.flatMap((m) => m.operations.map((o) => o.id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (bound.kind === "creation" && requirement.kind === "creation")
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
kind: "creation",
|
||||||
|
atom: bound.atomId,
|
||||||
|
schema: portableValueSchema(requirement.inputType),
|
||||||
|
recipe: hexDigest({
|
||||||
|
codec: "camino-creation-recipe-v1",
|
||||||
|
versions: plan.definition.versions,
|
||||||
|
operation: operation.key,
|
||||||
|
export: entry.id,
|
||||||
|
port: dependency.portId,
|
||||||
|
requirement,
|
||||||
|
binding: bound,
|
||||||
|
owners: owners.filter((owner) => owner.atom === bound.atomId),
|
||||||
|
edges: edges.filter((edge) =>
|
||||||
|
owners.some((owner) => owner.atom === bound.atomId && owner.id === edge.owner),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
owners: bound.owners.map((seed) => {
|
||||||
|
const owner = owners.find((o) => o.atom === bound.atomId && o.interface === seed.interfaceRevisionId);
|
||||||
|
if (!owner)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"MISSING_STORAGE_OWNER",
|
||||||
|
dependency.portId,
|
||||||
|
"Creation has no initialization owner",
|
||||||
|
);
|
||||||
|
return { owner: owner.id, inputPath: seed.inputPath };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
unsupported = `UNSUPPORTED_EXECUTION_PORT:${bound.kind}`;
|
||||||
|
return { ...common, kind: "unavailable" };
|
||||||
|
});
|
||||||
|
const ports = compilePorts(entry, binding.dependencies);
|
||||||
|
const check = entry.acceptanceExportId
|
||||||
|
? pkg.exports.find((candidate) => candidate.id === entry.acceptanceExportId)!
|
||||||
|
: undefined;
|
||||||
|
target = {
|
||||||
|
kind: "package",
|
||||||
|
package: binding.packageRevisionId,
|
||||||
|
export: binding.exportId,
|
||||||
|
ports,
|
||||||
|
...(entry.preparation ? { preparation: entry.preparation } : {}),
|
||||||
|
...(check
|
||||||
|
? { acceptance: { export: check.id, ports: compilePorts(check, binding.acceptanceDependencies ?? []) } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const inputChanges = preparedInputPaths(operation.input);
|
||||||
|
const input = schema(operation.input),
|
||||||
|
output = schema(operation.output);
|
||||||
|
return {
|
||||||
|
key: operation.key,
|
||||||
|
atom: operation.atomId,
|
||||||
|
interface: operation.interfaceRevisionId,
|
||||||
|
operation: operation.operationId,
|
||||||
|
scope: operation.scope,
|
||||||
|
effect: operation.execution.kind,
|
||||||
|
targets: operation.availableTargets,
|
||||||
|
input,
|
||||||
|
...(inputChanges.length ? { inputChanges } : {}),
|
||||||
|
output,
|
||||||
|
unsupported: unsupported ?? (input && output ? null : "UNSUPPORTED_OPERATION_CODEC"),
|
||||||
|
binding: target,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const packages = workspace.packageImports.map((pkg) => ({
|
||||||
|
revision: pkg.revisionId,
|
||||||
|
exports: pkg.exports.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
...(entry.preparation ? { preparation: entry.preparation } : {}),
|
||||||
|
...(entry.acceptanceExportId ? { acceptance: entry.acceptanceExportId } : {}),
|
||||||
|
effect: entry.execution?.kind ?? "remote",
|
||||||
|
targets: entry.execution && entry.execution.kind !== "remote" ? entry.execution.targets : [],
|
||||||
|
receiver:
|
||||||
|
entry.kind === "function"
|
||||||
|
? null
|
||||||
|
: entry.kind === "operation" && entry.receiverRequirement.kind === "exact-atom"
|
||||||
|
? { kind: "atom", id: entry.receiverRequirement.atomId }
|
||||||
|
: "object",
|
||||||
|
input: schema(entry.inputType),
|
||||||
|
output: schema(entry.outputType),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
const body = {
|
||||||
|
codec: "camino-kernel-program-v1",
|
||||||
|
model: { workspace: workspace.workspaceId, atoms: workspace.atoms.map((atom) => atom.id), owners, edges },
|
||||||
|
operations,
|
||||||
|
packages,
|
||||||
|
};
|
||||||
|
// The interpreter's plan key covers its real input, not source/build evidence.
|
||||||
|
return { ...body, plan: hexDigest({ versions: plan.definition.versions, program: body }) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { constants } from "node:fs";
|
||||||
|
import { open } from "node:fs/promises";
|
||||||
|
|
||||||
|
const admissionFailure = (code: string, kind: "file-bytes", measured: number, limit: number) =>
|
||||||
|
Object.assign(Error(code), { metadataAdmission: { kind, measured, limit } });
|
||||||
|
|
||||||
|
/** Admission estimates, not a hard V8 heap limit. A process/cgroup ceiling is
|
||||||
|
* still required. Charge input, parse trees and derived indexes before reading
|
||||||
|
* or parsing. Each live candidate retains its reservation until explicit close. */
|
||||||
|
export class CandidateMetadataBudget {
|
||||||
|
readonly limits: Readonly<{ bytes: number; ownerBytes: number; candidates: number }>;
|
||||||
|
private bytes = 0;
|
||||||
|
private scopes = 0;
|
||||||
|
private readonly owners = new Map<string, number>();
|
||||||
|
// A cutover holds both the serving and proposed installation plus transient
|
||||||
|
// parsing space. The Reader with checked Query Trees retains ~176 MiB per
|
||||||
|
// installation; its replacement also needs transient parse/index space.
|
||||||
|
// Keep the aggregate ceiling independent and leave room for parsing overlap.
|
||||||
|
constructor(limits = { bytes: 768 * 1024 * 1024, ownerBytes: 512 * 1024 * 1024, candidates: 128 }) {
|
||||||
|
for (const value of Object.values(limits))
|
||||||
|
if (!Number.isSafeInteger(value) || value < 1) throw Error("INVALID_CANDIDATE_METADATA_BUDGET");
|
||||||
|
if (limits.ownerBytes > limits.bytes) throw Error("INVALID_CANDIDATE_METADATA_BUDGET");
|
||||||
|
this.limits = Object.freeze({ ...limits });
|
||||||
|
}
|
||||||
|
snapshot() {
|
||||||
|
return { bytes: this.bytes, candidates: this.scopes, owners: this.owners.size, limits: this.limits };
|
||||||
|
}
|
||||||
|
ownedBytes(owner: string) {
|
||||||
|
return this.owners.get(owner) ?? 0;
|
||||||
|
}
|
||||||
|
acquire(owner: string) {
|
||||||
|
if (!owner || owner.length > 4096) throw Error("INVALID_CANDIDATE_METADATA_OWNER");
|
||||||
|
if (this.scopes >= this.limits.candidates) throw Error("CANDIDATE_METADATA_LIMIT");
|
||||||
|
this.scopes++;
|
||||||
|
let bytes = 0,
|
||||||
|
closed = false;
|
||||||
|
const charge = (amount: number) => {
|
||||||
|
if (closed) throw Error("CANDIDATE_METADATA_CLOSED");
|
||||||
|
if (!Number.isSafeInteger(amount) || amount < 0) throw Error("INVALID_CANDIDATE_METADATA_CHARGE");
|
||||||
|
const owned = this.owners.get(owner) ?? 0;
|
||||||
|
if (this.bytes + amount > this.limits.bytes || owned + amount > this.limits.ownerBytes)
|
||||||
|
throw Error("CANDIDATE_METADATA_LIMIT");
|
||||||
|
bytes += amount;
|
||||||
|
this.bytes += amount;
|
||||||
|
this.owners.set(owner, owned + amount);
|
||||||
|
};
|
||||||
|
const subtract = (amount: number) => {
|
||||||
|
bytes -= amount;
|
||||||
|
this.bytes -= amount;
|
||||||
|
const remaining = (this.owners.get(owner) ?? 0) - amount;
|
||||||
|
if (remaining) this.owners.set(owner, remaining);
|
||||||
|
else this.owners.delete(owner);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
get bytes() {
|
||||||
|
return bytes;
|
||||||
|
},
|
||||||
|
charge,
|
||||||
|
/** Opaque, idempotent transient reservation; callers cannot subtract an
|
||||||
|
* arbitrary amount or release another candidate's charges. */
|
||||||
|
reserve: (amount: number) => {
|
||||||
|
charge(amount);
|
||||||
|
let released = false;
|
||||||
|
return () => {
|
||||||
|
if (closed || released) return;
|
||||||
|
released = true;
|
||||||
|
subtract(amount);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
release: () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
subtract(bytes);
|
||||||
|
this.scopes--;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export type CandidateMetadataScope = ReturnType<CandidateMetadataBudget["acquire"]>;
|
||||||
|
export const candidateMetadataBudget = new CandidateMetadataBudget();
|
||||||
|
|
||||||
|
// Estimate JSON allocation without constructing its tree. Object/array headers,
|
||||||
|
// value slots and strings get explicit weights, with a multiplier for checked
|
||||||
|
// indexes/derived representations. Indentation must not multiply tree charges.
|
||||||
|
function jsonAllocation(bytes: Uint8Array): number {
|
||||||
|
let estimate = 0;
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
const c = bytes[i]!;
|
||||||
|
if (c === 123) estimate += 64;
|
||||||
|
else if (c === 91) estimate += 32;
|
||||||
|
else if (c === 34) {
|
||||||
|
const start = ++i;
|
||||||
|
while (i < bytes.length && bytes[i] !== 34) {
|
||||||
|
if (bytes[i] === 92) i++;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
estimate += 32 + 2 * (i - start);
|
||||||
|
} else if (c === 45 || (c >= 48 && c <= 57) || c === 116 || c === 102 || c === 110) {
|
||||||
|
estimate += 16;
|
||||||
|
while (i + 1 < bytes.length) {
|
||||||
|
const next = bytes[i + 1]!;
|
||||||
|
if (next === 9 || next === 10 || next === 13 || next === 32 || next === 44 || next === 93 || next === 125)
|
||||||
|
break;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return estimate * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Immutable regular files only. Fixed allocation plus an extra byte catches
|
||||||
|
* growth; readFile() would allocate before the admission check can intervene. */
|
||||||
|
export async function readCandidateMetadata(
|
||||||
|
path: string,
|
||||||
|
scope: CandidateMetadataScope,
|
||||||
|
limit: number,
|
||||||
|
): Promise<{ bytes: Uint8Array; releaseInput: () => void; releaseTree: () => void }> {
|
||||||
|
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||||
|
let releaseInput: (() => void) | undefined;
|
||||||
|
try {
|
||||||
|
const stat = await handle.stat();
|
||||||
|
if (!stat.isFile() || !Number.isSafeInteger(stat.size)) throw Error("CANDIDATE_METADATA_FILE_LIMIT");
|
||||||
|
if (stat.size > limit) throw admissionFailure("CANDIDATE_METADATA_FILE_LIMIT", "file-bytes", stat.size, limit);
|
||||||
|
// Reserve the input buffer and UTF-16 decoding before allocating either.
|
||||||
|
releaseInput = scope.reserve((stat.size + 1) * 3);
|
||||||
|
const bytes = Buffer.alloc(stat.size + 1);
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < bytes.length) {
|
||||||
|
const read = await handle.read(bytes, offset, bytes.length - offset, offset);
|
||||||
|
if (!read.bytesRead) break;
|
||||||
|
offset += read.bytesRead;
|
||||||
|
}
|
||||||
|
if (offset !== stat.size) throw Error("CANDIDATE_METADATA_CHANGED");
|
||||||
|
const content = bytes.subarray(0, offset);
|
||||||
|
// No JSON.parse/decoded tree exists until its shape-based charge succeeds.
|
||||||
|
const releaseTree = scope.reserve(jsonAllocation(content));
|
||||||
|
return { bytes: content, releaseInput, releaseTree };
|
||||||
|
} catch (error) {
|
||||||
|
releaseInput?.();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { InterfaceRevision } from "../capability-model/types.js";
|
||||||
|
|
||||||
|
/** Public, checked type information for artifact consumers. It describes the
|
||||||
|
* nominal role, without exposing implementations or synthesizing any storage. */
|
||||||
|
export function moduleTypeContext(iface: InterfaceRevision): {
|
||||||
|
typeArguments?: readonly unknown[];
|
||||||
|
requiredInterfaces?: readonly string[];
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
...(iface.application?.arguments.length
|
||||||
|
? {
|
||||||
|
typeArguments: structuredClone(iface.application.arguments),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(iface.requiredInterfaces?.length ? { requiredInterfaces: [...iface.requiredInterfaces].sort() } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Binding } from "../capability-model/types.js";
|
||||||
|
|
||||||
|
/** Nominal library identity. Backends implement primitives; target names carry no
|
||||||
|
* built-in semantics in the graph planner. The JS backend is installed by the
|
||||||
|
* current toolchain, alongside the authoritative server executor. */
|
||||||
|
export const javascriptTarget = "target:org.quixos.execution.javascript@1";
|
||||||
|
export interface NativeExecutionBackend {
|
||||||
|
readonly target: string;
|
||||||
|
readonly supports: (binding: Exclude<Binding, { kind: "interface" }>) => boolean;
|
||||||
|
}
|
||||||
|
export const standardNativeBackends: readonly NativeExecutionBackend[] = [
|
||||||
|
{ target: "server", supports: () => true },
|
||||||
|
{
|
||||||
|
target: javascriptTarget,
|
||||||
|
supports: (binding) =>
|
||||||
|
binding.kind === "state" ||
|
||||||
|
binding.kind === "edge" ||
|
||||||
|
binding.kind === "journal" ||
|
||||||
|
(binding.kind === "subscription" && binding.primitive === "status"),
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import { executionPlanDigest, ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js";
|
||||||
|
import { parse } from "@babel/parser";
|
||||||
|
import { builtinModules } from "node:module";
|
||||||
|
|
||||||
|
import type { ExecutionInstallation } from "./installation.js";
|
||||||
|
import type { ArtifactImplementation, ArtifactReader } from "./artifacts.js";
|
||||||
|
|
||||||
|
export interface PlacementBlocker {
|
||||||
|
readonly code: string;
|
||||||
|
readonly path: readonly string[];
|
||||||
|
readonly detail: string;
|
||||||
|
}
|
||||||
|
export interface PlacementEntry {
|
||||||
|
readonly operation: string;
|
||||||
|
readonly effect: string;
|
||||||
|
readonly availableTargets: readonly string[];
|
||||||
|
readonly requiresVM: boolean;
|
||||||
|
readonly nativeBindings: readonly string[];
|
||||||
|
readonly cycles: readonly (readonly string[])[];
|
||||||
|
readonly blockers: readonly PlacementBlocker[];
|
||||||
|
}
|
||||||
|
export interface ImportFinding {
|
||||||
|
readonly code: string;
|
||||||
|
readonly detail: string;
|
||||||
|
readonly line: number | null;
|
||||||
|
}
|
||||||
|
export const restrictedSDKSpecifier = "quixos:restricted-sdk/v1";
|
||||||
|
export const restrictedSDKPath = "share/quixos/restricted/core.mjs";
|
||||||
|
|
||||||
|
/** Compare trusted SDK syntax without build-machine paths in source comments.
|
||||||
|
* Keep literal/directive spelling and every executable AST node. This is SDK
|
||||||
|
* compatibility only: artifact files and the executed host SDK still have exact
|
||||||
|
* byte digests, and authored SDK bytes are never loaded by the restricted host.
|
||||||
|
*/
|
||||||
|
export function restrictedSDKIdentity(source: string): string {
|
||||||
|
if (Buffer.byteLength(source) > 2 * 1024 * 1024) throw Error("RESTRICTED_SDK_ANALYSIS_LIMIT");
|
||||||
|
const syntax = parse(source, { sourceType: "module", attachComment: false });
|
||||||
|
const metadata = new Set([
|
||||||
|
"start",
|
||||||
|
"end",
|
||||||
|
"loc",
|
||||||
|
"comments",
|
||||||
|
"leadingComments",
|
||||||
|
"innerComments",
|
||||||
|
"trailingComments",
|
||||||
|
"parenStart",
|
||||||
|
]);
|
||||||
|
let nodes = 0;
|
||||||
|
const encoded = JSON.stringify(syntax, (key, value) => {
|
||||||
|
if (metadata.has(key)) return undefined;
|
||||||
|
if (value && typeof value === "object" && typeof value.type === "string" && ++nodes > 100000)
|
||||||
|
throw Error("RESTRICTED_SDK_ANALYSIS_LIMIT");
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
return hexDigest({ codec: "quixos-restricted-sdk-syntax-v1", syntax: JSON.parse(encoded) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mechanical candidate checks, not authorization or an engine-safety proof.
|
||||||
|
* The caller must first verify the manifest and all declared file digests. */
|
||||||
|
export async function inspectRestrictedImplementation(
|
||||||
|
revision: string,
|
||||||
|
implementation: ArtifactImplementation,
|
||||||
|
read: ArtifactReader,
|
||||||
|
qualifiedSDKIdentity?: string,
|
||||||
|
): Promise<ImportFinding[]> {
|
||||||
|
const entry = implementation.files.find((file) => file.path === implementation.path);
|
||||||
|
const sdk = implementation.files.find((file) => file.path === restrictedSDKPath);
|
||||||
|
if (
|
||||||
|
implementation.compatibility !== "quixos-restricted-module-v1" ||
|
||||||
|
implementation.target !== "server" ||
|
||||||
|
implementation.files.length !== 2 ||
|
||||||
|
!entry ||
|
||||||
|
!sdk ||
|
||||||
|
entry === sdk ||
|
||||||
|
entry.mediaType !== "text/javascript" ||
|
||||||
|
sdk.mediaType !== "text/javascript" ||
|
||||||
|
!/^[$A-Z_a-z][$\w]*$/.test(implementation.registryExport ?? "")
|
||||||
|
)
|
||||||
|
return [{ code: "RESTRICTED_CLOSURE", detail: "Expected one closed module and the restricted SDK", line: null }];
|
||||||
|
if (entry.bytes > 1024 * 1024 || sdk.bytes > 2 * 1024 * 1024)
|
||||||
|
return [{ code: "ANALYSIS_LIMIT", detail: "Restricted closure exceeds the source budget", line: null }];
|
||||||
|
if (qualifiedSDKIdentity) {
|
||||||
|
const source = Buffer.from(await read(revision, sdk.path, sdk.bytes)).toString("utf8");
|
||||||
|
try {
|
||||||
|
if (restrictedSDKIdentity(source) !== qualifiedSDKIdentity) throw Error("SDK syntax differs");
|
||||||
|
} catch {
|
||||||
|
return [
|
||||||
|
{ code: "RESTRICTED_SDK_UNSUPPORTED", detail: "SDK differs from the qualified host profile", line: null },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inspectJavaScriptSurface(Buffer.from(await read(revision, entry.path, entry.bytes)).toString("utf8"), [
|
||||||
|
restrictedSDKSpecifier,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const nativeImports = new Set(builtinModules.flatMap((name) => [name, name.replace(/^node:/, "")]));
|
||||||
|
const ambient = new Set([
|
||||||
|
"process",
|
||||||
|
"global",
|
||||||
|
"globalThis",
|
||||||
|
"fetch",
|
||||||
|
"WebSocket",
|
||||||
|
"XMLHttpRequest",
|
||||||
|
"Deno",
|
||||||
|
"Bun",
|
||||||
|
"eval",
|
||||||
|
"Function",
|
||||||
|
"SharedArrayBuffer",
|
||||||
|
"Atomics",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Diagnostic scan of compiled JavaScript, never a certificate of purity. A
|
||||||
|
* shadowed name can conservatively produce a finding; computed access/aliases
|
||||||
|
* cannot be proven safe here. No findings still require a qualified loader and
|
||||||
|
* runtime before this artifact can enter restricted execution. */
|
||||||
|
export function inspectJavaScriptSurface(source: string, checkedSDKImports: readonly string[] = []): ImportFinding[] {
|
||||||
|
if (Buffer.byteLength(source) > 1024 * 1024)
|
||||||
|
return [{ code: "ANALYSIS_LIMIT", detail: "JavaScript exceeds the 1 MiB inspection budget", line: null }];
|
||||||
|
let ast;
|
||||||
|
try {
|
||||||
|
ast = parse(source, { sourceType: "module", createImportExpressions: true });
|
||||||
|
} catch {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
code: "UNCLASSIFIED_SYNTAX",
|
||||||
|
detail: "Compiled source cannot be classified by the selected parser",
|
||||||
|
line: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const findings: ImportFinding[] = [],
|
||||||
|
seen = new Set<string>(),
|
||||||
|
sdk = new Set(checkedSDKImports);
|
||||||
|
const add = (code: string, detail: string, node: any) => {
|
||||||
|
const key = JSON.stringify([code, detail]);
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
if (findings.length < 63) findings.push({ code, detail, line: node?.loc?.start.line ?? null });
|
||||||
|
};
|
||||||
|
const imported = (node: any, at: any) => {
|
||||||
|
if (node?.type !== "StringLiteral") {
|
||||||
|
add("COMPUTED_MODULE_LOADING", "Module name is computed", at);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name: string = node.value;
|
||||||
|
if (sdk.has(name)) return;
|
||||||
|
add(
|
||||||
|
name.startsWith("node:") || nativeImports.has(name) ? "NATIVE_IMPORT" : "UNCLASSIFIED_IMPORT",
|
||||||
|
name.slice(0, 256),
|
||||||
|
at,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const pending: { node: any; parent?: any; key?: string }[] = [{ node: ast }];
|
||||||
|
let visited = 0;
|
||||||
|
while (pending.length) {
|
||||||
|
if (++visited > 100000) {
|
||||||
|
add("ANALYSIS_LIMIT", "JavaScript exceeds the syntax-node inspection budget", null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const { node, parent, key } = pending.pop()!;
|
||||||
|
if (!node || typeof node !== "object") continue;
|
||||||
|
if (["ImportDeclaration", "ExportAllDeclaration", "ExportNamedDeclaration"].includes(node.type) && node.source)
|
||||||
|
imported(node.source, node);
|
||||||
|
if (node.type === "ImportExpression") imported(node.source, node);
|
||||||
|
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "require")
|
||||||
|
imported(node.arguments[0], node);
|
||||||
|
const propertyName = key === "property" && parent?.type === "MemberExpression" && !parent.computed;
|
||||||
|
const recordKey =
|
||||||
|
key === "key" &&
|
||||||
|
["ObjectProperty", "ObjectMethod"].includes(parent?.type) &&
|
||||||
|
!parent.computed &&
|
||||||
|
!parent.shorthand;
|
||||||
|
if (node.type === "Identifier" && ambient.has(node.name) && !propertyName && !recordKey)
|
||||||
|
add("AMBIENT_HOST_ACCESS", node.name, node);
|
||||||
|
if (
|
||||||
|
node.type === "MemberExpression" &&
|
||||||
|
node.object?.type === "Identifier" &&
|
||||||
|
node.object.name === "Math" &&
|
||||||
|
(node.computed || node.property?.name === "random")
|
||||||
|
)
|
||||||
|
add("AMBIENT_RANDOMNESS", "Use captured input or a frame-scoped seed", node);
|
||||||
|
if (
|
||||||
|
(node.type === "CallExpression" || node.type === "NewExpression") &&
|
||||||
|
node.callee?.type === "Identifier" &&
|
||||||
|
node.callee.name === "Date" &&
|
||||||
|
node.arguments.length === 0
|
||||||
|
)
|
||||||
|
add("AMBIENT_CLOCK", "Use captured time; date parsing is allowed", node);
|
||||||
|
if (
|
||||||
|
node.type === "MemberExpression" &&
|
||||||
|
node.object?.type === "Identifier" &&
|
||||||
|
node.object.name === "Date" &&
|
||||||
|
(node.computed || node.property?.name === "now")
|
||||||
|
)
|
||||||
|
add("AMBIENT_CLOCK", "Use captured time; date parsing is allowed", node);
|
||||||
|
for (const [childKey, child] of Object.entries(node)) {
|
||||||
|
if (["loc", "start", "end", "extra", "comments", "tokens"].includes(childKey)) continue;
|
||||||
|
if (Array.isArray(child)) {
|
||||||
|
for (const value of child)
|
||||||
|
if (value && typeof value === "object") pending.push({ node: value, parent: node, key: childKey });
|
||||||
|
} else if (child && typeof child === "object") pending.push({ node: child, parent: node, key: childKey });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seen.size > findings.length)
|
||||||
|
findings.push({ code: "ANALYSIS_LIMIT", detail: "Additional findings omitted", line: null });
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derived metadata only. A build report can describe a closed candidate but
|
||||||
|
* cannot qualify the host's engine, SDK, OS sandbox or resource controller. */
|
||||||
|
export async function reportExecutionPlacement(
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
installation: ExecutionInstallation,
|
||||||
|
read: ArtifactReader,
|
||||||
|
) {
|
||||||
|
if (installation.plan !== plan.digest || executionPlanDigest(plan) !== plan.digest)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"PLACEMENT_PLAN_MISMATCH",
|
||||||
|
plan.digest,
|
||||||
|
"Expected the selected checked plan and installation",
|
||||||
|
);
|
||||||
|
const operations = new Map(plan.definition.operations.map((entry) => [entry.key, entry]));
|
||||||
|
const queries = new Map(plan.definition.queries.map((entry) => [entry.id, entry]));
|
||||||
|
const packages = await Promise.all(
|
||||||
|
installation.packages.map(async (pkg) => {
|
||||||
|
const implementations: { id: string; digest: string; compatibility: string; findings: ImportFinding[] }[] = [];
|
||||||
|
for (const implementation of [pkg.service, pkg.module, pkg.restricted].filter(
|
||||||
|
(entry): entry is ArtifactImplementation => !!entry,
|
||||||
|
)) {
|
||||||
|
let findings: ImportFinding[];
|
||||||
|
if (implementation.compatibility === "quixos-restricted-module-v1")
|
||||||
|
findings = await inspectRestrictedImplementation(pkg.revision, implementation, read);
|
||||||
|
else if (implementation.compatibility === "quixos-js-module-v1") {
|
||||||
|
const file = implementation.files.find((file) => file.path === implementation.path)!;
|
||||||
|
if (!file || file.bytes > 1024 * 1024)
|
||||||
|
findings = [
|
||||||
|
{ code: "ANALYSIS_LIMIT", detail: "Entry source unavailable within the inspection budget", line: null },
|
||||||
|
];
|
||||||
|
else {
|
||||||
|
const sdk = implementation.files.filter((file) => file.path.endsWith("/core.mjs"));
|
||||||
|
const allowed = sdk.flatMap((file) => [
|
||||||
|
`${pkg.root}/${file.path}`,
|
||||||
|
`/__quixos/artifacts/content/${file.digest}/core.mjs`,
|
||||||
|
]);
|
||||||
|
findings = inspectJavaScriptSurface(
|
||||||
|
Buffer.from(await read(pkg.revision, file.path, file.bytes)).toString("utf8"),
|
||||||
|
allowed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
findings = [
|
||||||
|
{ code: "NATIVE_SERVICE", detail: "Native/persistent service stays in VM execution", line: null },
|
||||||
|
];
|
||||||
|
implementations.push({
|
||||||
|
id: implementation.id,
|
||||||
|
digest: implementation.contentDigest,
|
||||||
|
compatibility: implementation.compatibility,
|
||||||
|
findings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
revision: pkg.revision,
|
||||||
|
requiresVM: implementations.length > 0,
|
||||||
|
restrictedEligible: false as const,
|
||||||
|
reason: pkg.restricted ? "HOST_PROFILE_QUALIFICATION_REQUIRED" : "NO_RESTRICTED_IMPLEMENTATION",
|
||||||
|
restrictedCandidate: pkg.restricted
|
||||||
|
? {
|
||||||
|
digest: pkg.restricted.contentDigest,
|
||||||
|
exports: pkg.restricted.exports,
|
||||||
|
compatible: implementations.find((entry) => entry.id === pkg.restricted!.id)!.findings.length === 0,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
implementations,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const entries: PlacementEntry[] = plan.definition.operations.map((root) => {
|
||||||
|
const native = new Set<string>(),
|
||||||
|
visited = new Set<string>(),
|
||||||
|
cycles: string[][] = [],
|
||||||
|
blockers: PlacementBlocker[] = [];
|
||||||
|
const pending = [{ key: root.key, path: [root.key] }];
|
||||||
|
let limited = false;
|
||||||
|
const block = (code: string, path: string[], detail: string) => {
|
||||||
|
if (blockers.length < 63) blockers.push({ code, path, detail });
|
||||||
|
else limited = true;
|
||||||
|
};
|
||||||
|
while (pending.length) {
|
||||||
|
if (visited.size >= 512) {
|
||||||
|
limited = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const { key, path } = pending.pop()!;
|
||||||
|
const repeated = path.slice(0, -1).indexOf(key);
|
||||||
|
if (repeated >= 0) {
|
||||||
|
if (cycles.length < 16) cycles.push(path.slice(repeated));
|
||||||
|
else limited = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (visited.has(key)) continue;
|
||||||
|
if (path.length > 128) {
|
||||||
|
limited = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visited.add(key);
|
||||||
|
const node = operations.get(key);
|
||||||
|
if (!node) {
|
||||||
|
block("UNCLASSIFIED_DEPENDENCY", path, "Operation is absent from the checked closure");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (node.execution.kind === "remote")
|
||||||
|
block("REMOTE_EFFECT", path, "Remote effects require VM or separately approved effect authority");
|
||||||
|
if (node.binding.kind === "package")
|
||||||
|
block(
|
||||||
|
"UNQUALIFIED_PACKAGE",
|
||||||
|
path,
|
||||||
|
`${node.binding.packageRevisionId}/${node.binding.exportId}: no qualified restricted implementation`,
|
||||||
|
);
|
||||||
|
else if (["state", "edge", "journal", "subscription", "lifecycle"].includes(node.binding.kind))
|
||||||
|
native.add(node.binding.kind);
|
||||||
|
else block("UNCLASSIFIED_NATIVE", path, node.binding.kind);
|
||||||
|
for (const dependency of node.dependencies) {
|
||||||
|
if (dependency.kind === "operation")
|
||||||
|
pending.push({ key: dependency.operation, path: [...path, dependency.port, dependency.operation] });
|
||||||
|
if (dependency.kind === "query") {
|
||||||
|
const query = queries.get(dependency.query);
|
||||||
|
if (!query) block("UNCLASSIFIED_QUERY", [...path, dependency.port], dependency.query);
|
||||||
|
else
|
||||||
|
for (const operation of new Set([...query.operations, ...query.admittedOperations]))
|
||||||
|
pending.push({ key: operation, path: [...path, dependency.port, dependency.query, operation] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (limited)
|
||||||
|
blockers.push({
|
||||||
|
code: "ANALYSIS_LIMIT",
|
||||||
|
path: [root.key],
|
||||||
|
detail: "Dependency closure exceeds the bounded report; keep VM placement",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
operation: root.key,
|
||||||
|
effect: root.execution.kind,
|
||||||
|
availableTargets: [...root.availableTargets],
|
||||||
|
requiresVM: blockers.length > 0,
|
||||||
|
nativeBindings: [...native].sort(),
|
||||||
|
cycles,
|
||||||
|
blockers,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const background = plan.definition.packages.flatMap((pkg) =>
|
||||||
|
(pkg.controllers ?? []).map((controller) => ({
|
||||||
|
package: pkg.revision,
|
||||||
|
id: controller.id,
|
||||||
|
requiresVM: true,
|
||||||
|
reason: "PERSISTENT_CONTROLLER_EXECUTION",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
codec: "quixos-execution-placement-v1" as const,
|
||||||
|
workspace: plan.definition.workspace.workspaceId,
|
||||||
|
plan: plan.digest,
|
||||||
|
requiresVM:
|
||||||
|
packages.some((pkg) => pkg.requiresVM) || entries.some((entry) => entry.requiresVM) || background.length > 0,
|
||||||
|
operations: entries,
|
||||||
|
packages,
|
||||||
|
background,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
import { standardNativeBackends, type NativeExecutionBackend } from "./native-backends.js";
|
||||||
|
import type {
|
||||||
|
WorkspaceRevision,
|
||||||
|
ExecutionContract,
|
||||||
|
ValueType,
|
||||||
|
Binding,
|
||||||
|
DependencyBinding,
|
||||||
|
EdgeTraversal,
|
||||||
|
InterfaceRevisionId,
|
||||||
|
AtomId,
|
||||||
|
OperationId,
|
||||||
|
} from "../capability-model/types.js";
|
||||||
|
import { validateWorkspaceRevision } from "../capability-model/validation.js";
|
||||||
|
import { canonicalJson, contentDigest, contentDigestAsync } from "../capability-model/evolution.js";
|
||||||
|
import { resolveForwardedBinding } from "../capability-model/forwarding.js";
|
||||||
|
import { linkQueries, type LinkedQuery } from "../query/link.js";
|
||||||
|
import { type BindingSchema } from "../bindings/index.js";
|
||||||
|
import { unsupportedQueryOperator } from "./query-operators.js";
|
||||||
|
import { packageBuildContexts } from "./build-contract.js";
|
||||||
|
import { setImmediate } from "node:timers/promises";
|
||||||
|
|
||||||
|
export class ExecutionPlanError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: string,
|
||||||
|
readonly path: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(`${code} ${path}: ${message}`);
|
||||||
|
this.name = "ExecutionPlanError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const hexDigest = (value: unknown): string => contentDigest(value).slice("sha256:".length);
|
||||||
|
export const hexDigestAsync = async (value: unknown): Promise<string> =>
|
||||||
|
(await contentDigestAsync(value)).slice("sha256:".length);
|
||||||
|
// Checked runtime metadata is owned JSON, not live Graph data. Reuse its proof
|
||||||
|
// only after every reachable record is immutable. Weak keys retain neither old
|
||||||
|
// Candidates nor an application cache; unsealed compiler inputs are rehashed.
|
||||||
|
const sealedPlanDigests = new WeakMap<ExecutionPlan, string>();
|
||||||
|
export const executionPlanDigest = (plan: ExecutionPlan): string =>
|
||||||
|
sealedPlanDigests.get(plan) ?? hexDigest(plan.definition);
|
||||||
|
export const executionPlanDigestAsync = async (plan: ExecutionPlan): Promise<string> =>
|
||||||
|
sealedPlanDigests.get(plan) ?? (await hexDigestAsync(plan.definition));
|
||||||
|
/** Transfers owned parsed JSON into immutable runtime metadata. Freeze before
|
||||||
|
* yielding to hashing so concurrent mutation cannot invalidate the proof. */
|
||||||
|
export async function sealExecutionPlan(plan: ExecutionPlan): Promise<boolean> {
|
||||||
|
if (sealedPlanDigests.has(plan)) return true;
|
||||||
|
if (!Object.hasOwn(plan, "definition") || !Object.hasOwn(plan, "digest")) return false;
|
||||||
|
const pending: object[] = [plan],
|
||||||
|
seen = new WeakSet<object>();
|
||||||
|
let sliceStarted = performance.now(),
|
||||||
|
count = 0;
|
||||||
|
while (pending.length) {
|
||||||
|
const value = pending.pop()!;
|
||||||
|
if (seen.has(value)) continue;
|
||||||
|
if (
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
Object.getPrototypeOf(value) !== Object.prototype &&
|
||||||
|
Object.getPrototypeOf(value) !== null
|
||||||
|
)
|
||||||
|
throw new ExecutionPlanError("INVALID_PLAN_JSON", "plan", "Expected owned JSON records");
|
||||||
|
for (const key of Reflect.ownKeys(value)) {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
|
||||||
|
if (typeof key !== "string" || !Object.hasOwn(descriptor, "value"))
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"INVALID_PLAN_JSON",
|
||||||
|
"plan",
|
||||||
|
"Runtime metadata cannot contain accessors or symbols",
|
||||||
|
);
|
||||||
|
if (descriptor.value !== null && typeof descriptor.value === "object") pending.push(descriptor.value);
|
||||||
|
}
|
||||||
|
Object.freeze(value);
|
||||||
|
seen.add(value);
|
||||||
|
if (++count % 1024 === 0 && performance.now() - sliceStarted >= 8) {
|
||||||
|
await setImmediate();
|
||||||
|
sliceStarted = performance.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const digest = await hexDigestAsync(plan.definition);
|
||||||
|
if (digest !== plan.digest) return false;
|
||||||
|
sealedPlanDigests.set(plan, digest);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const sorted = <T>(values: readonly T[], key: (value: T) => string): T[] =>
|
||||||
|
[...values].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
||||||
|
export const publicOperationKey = (atom: string, iface: string, operation: string): string =>
|
||||||
|
JSON.stringify([atom, iface, operation]);
|
||||||
|
export type DataDependency =
|
||||||
|
| { kind: "state"; slot: string; via?: EdgeTraversal }
|
||||||
|
| { kind: "edge"; edge: string; projection: string; via?: EdgeTraversal }
|
||||||
|
| { kind: "operation"; operation: string; port: string; via?: EdgeTraversal }
|
||||||
|
| { kind: "query"; query: string; port: string };
|
||||||
|
export interface PlannedOperation {
|
||||||
|
key: string;
|
||||||
|
atomId: string;
|
||||||
|
interfaceRevisionId: string;
|
||||||
|
operationId: string;
|
||||||
|
scope: "object" | "class";
|
||||||
|
execution: ExecutionContract;
|
||||||
|
/** Targets available for this complete installed implementation/dependency closure. */
|
||||||
|
availableTargets: string[];
|
||||||
|
input: ValueType;
|
||||||
|
output: ValueType;
|
||||||
|
/** Concrete target after public forwarding. Never chosen by a runtime caller. */
|
||||||
|
binding: Exclude<Binding, { kind: "interface" }>;
|
||||||
|
dependencies: DataDependency[];
|
||||||
|
}
|
||||||
|
export interface PlannedQuery {
|
||||||
|
id: string;
|
||||||
|
query: LinkedQuery;
|
||||||
|
/** Query-selected getter entrypoints; dependency traversal supplies canonical inputs. */
|
||||||
|
operations: string[];
|
||||||
|
/** Local query roots that admission must support, in addition to selected wire outputs. */
|
||||||
|
admittedOperations: string[];
|
||||||
|
}
|
||||||
|
export interface PlanPackage {
|
||||||
|
modules?: (NonNullable<WorkspaceRevision["packageImports"][number]["modules"]>[number] & {
|
||||||
|
contract: string;
|
||||||
|
subject?: import("../capability-model/types.js").ObjectExpectation;
|
||||||
|
typeArguments?: readonly unknown[];
|
||||||
|
requiredInterfaces?: readonly string[];
|
||||||
|
})[];
|
||||||
|
controllers?: NonNullable<WorkspaceRevision["packageImports"][number]["controllers"]>;
|
||||||
|
components?: { id: string; displayName: string; subject: InterfaceRevisionId; runtime: "react" }[];
|
||||||
|
revision: string;
|
||||||
|
source: WorkspaceRevision["packageImports"][number]["source"];
|
||||||
|
sourceDigest: string;
|
||||||
|
bindingDigest: string;
|
||||||
|
exports: { id: string; execution: ExecutionContract }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionPlanDefinition {
|
||||||
|
presentations?: NonNullable<WorkspaceRevision["presentations"]>;
|
||||||
|
codec: "camino-execution-plan-v1";
|
||||||
|
workspace: WorkspaceRevision;
|
||||||
|
bindingSchema: BindingSchema;
|
||||||
|
packages: PlanPackage[];
|
||||||
|
owners: { id: string; atom: string; interface: string; initialization: unknown; storage: unknown }[];
|
||||||
|
operations: PlannedOperation[];
|
||||||
|
queries: PlannedQuery[];
|
||||||
|
profiles: { slot: string; profile: string }[];
|
||||||
|
versions: {
|
||||||
|
value: "camino-value-v1";
|
||||||
|
reference: "camino-ref-v1";
|
||||||
|
ports: "quixos-transaction-ports-v1";
|
||||||
|
crdt: "camino-automerge-3.4.1-structured-v1";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface ExecutionPlan {
|
||||||
|
definition: ExecutionPlanDefinition;
|
||||||
|
digest: string;
|
||||||
|
}
|
||||||
|
export const executionVersions: ExecutionPlanDefinition["versions"] = {
|
||||||
|
value: "camino-value-v1",
|
||||||
|
reference: "camino-ref-v1",
|
||||||
|
ports: "quixos-transaction-ports-v1",
|
||||||
|
crdt: "camino-automerge-3.4.1-structured-v1",
|
||||||
|
};
|
||||||
|
/** Selection only: the coordinator separately verifies the caller's grant and receiver witness. */
|
||||||
|
export function resolvePlannedOperation(
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
atom: string,
|
||||||
|
iface: string,
|
||||||
|
operation: string,
|
||||||
|
): PlannedOperation {
|
||||||
|
const key = publicOperationKey(atom, iface, operation);
|
||||||
|
const selected = plan.definition.operations.find((entry) => entry.key === key);
|
||||||
|
if (!selected) throw new ExecutionPlanError("UNINSTALLED_OPERATION", key, "No installed public operation binding");
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bound argument/state codec subset. A parsed type alone does not mean a runtime implements it. */
|
||||||
|
function checkValue(type: ValueType, path: string, allowChange = false): void {
|
||||||
|
switch (type.kind) {
|
||||||
|
case "variant":
|
||||||
|
for (const [label, entry] of Object.entries(type.cases)) checkValue(entry.value, `${path}.${label}`, allowChange);
|
||||||
|
return;
|
||||||
|
case "scalar":
|
||||||
|
case "object-ref":
|
||||||
|
return;
|
||||||
|
case "builtin":
|
||||||
|
if (type.name === "unit") return;
|
||||||
|
break;
|
||||||
|
case "optional":
|
||||||
|
case "list":
|
||||||
|
checkValue(type.value, path, allowChange);
|
||||||
|
return;
|
||||||
|
case "record":
|
||||||
|
for (const [name, field] of Object.entries(type.fields)) checkValue(field, `${path}.${name}`, allowChange);
|
||||||
|
return;
|
||||||
|
case "change":
|
||||||
|
if (allowChange) {
|
||||||
|
checkValue(type.value, path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
throw new ExecutionPlanError("UNSUPPORTED_PORTABLE_CODEC", path, `Portable execution does not support ${type.kind}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compile declarations into a closed dispatch/dependency graph before artifact selection. */
|
||||||
|
export function compileExecutionPlan(
|
||||||
|
input: WorkspaceRevision,
|
||||||
|
backends: readonly NativeExecutionBackend[] = standardNativeBackends,
|
||||||
|
): ExecutionPlan {
|
||||||
|
const workspace = structuredClone(input);
|
||||||
|
const issues = validateWorkspaceRevision(workspace);
|
||||||
|
if (issues.length)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"INVALID_WORKSPACE",
|
||||||
|
workspace.id,
|
||||||
|
issues.map((i) => `${i.path}: ${i.message}`).join("\n"),
|
||||||
|
);
|
||||||
|
const interfaces = new Map(workspace.interfaceImports.map((i) => [i.revisionId, i]));
|
||||||
|
const packages = new Map(workspace.packageImports.map((p) => [p.revisionId, p]));
|
||||||
|
const attachments = [...workspace.sharedAttachments, ...workspace.conformances.flatMap((c) => c.privateAttachments)];
|
||||||
|
for (const pkg of workspace.packageImports)
|
||||||
|
for (const entry of pkg.exports)
|
||||||
|
if (entry.execution && entry.execution.kind !== "remote") {
|
||||||
|
checkValue(entry.inputType, `${pkg.revisionId}/${entry.id}/input`, true);
|
||||||
|
checkValue(entry.outputType, `${pkg.revisionId}/${entry.id}/output`, true);
|
||||||
|
}
|
||||||
|
for (const attachment of attachments)
|
||||||
|
if (attachment.kind === "state" && attachment.storagePolicy.kind === "crdt-document")
|
||||||
|
checkValue(attachment.valueType, attachment.id);
|
||||||
|
const operationMap = new Map<string, PlannedOperation>();
|
||||||
|
const queries = linkQueries(workspace);
|
||||||
|
const queryKey = (pkg: string, id: string) => JSON.stringify([pkg, id]);
|
||||||
|
const queryMap = new Map(queries.map((q) => [queryKey(q.packageRevisionId, q.checked.declaration.id), q]));
|
||||||
|
const declaration = (iface: InterfaceRevisionId, operation: OperationId) =>
|
||||||
|
interfaces
|
||||||
|
.get(iface)
|
||||||
|
?.members.flatMap((m) => m.operations)
|
||||||
|
.find((o) => o.id === operation);
|
||||||
|
const queryOperations = (query: LinkedQuery) =>
|
||||||
|
query.fields.map((f) => publicOperationKey(f.atomId, f.interfaceRevisionId, f.getter));
|
||||||
|
const targetAtoms = (binding: DependencyBinding, atom: AtomId): AtomId[] => {
|
||||||
|
if (binding.kind === "interface" && binding.receiver === "input")
|
||||||
|
return workspace.atoms
|
||||||
|
.filter((candidate) =>
|
||||||
|
workspace.conformances.some(
|
||||||
|
(conformance) =>
|
||||||
|
conformance.atomId === candidate.id && conformance.interfaceRevisionId === binding.interfaceRevisionId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((candidate) => candidate.id);
|
||||||
|
if (!("via" in binding) || !binding.via) return [atom];
|
||||||
|
const edge = attachments.find((a) => a.id === binding.via!.edgeTypeId);
|
||||||
|
if (!edge || edge.kind !== "edge") throw new ExecutionPlanError("MISSING_EDGE", atom, "Missing traversal edge");
|
||||||
|
const endpoint = edge.endpoints.find((e) => e.projectionId !== binding.via!.projectionId)!;
|
||||||
|
const constraint = endpoint.constraint;
|
||||||
|
return workspace.atoms
|
||||||
|
.filter((a) =>
|
||||||
|
constraint.kind === "atom"
|
||||||
|
? constraint.atomId === a.id
|
||||||
|
: workspace.conformances.some(
|
||||||
|
(c) => c.atomId === a.id && c.interfaceRevisionId === constraint.interfaceRevisionId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((a) => a.id);
|
||||||
|
};
|
||||||
|
const add = (atom: AtomId, iface: InterfaceRevisionId, operation: OperationId): PlannedOperation => {
|
||||||
|
const key = publicOperationKey(atom, iface, operation),
|
||||||
|
existing = operationMap.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
const publicContract = declaration(iface, operation);
|
||||||
|
if (!publicContract) throw new ExecutionPlanError("MISSING_OPERATION", key, "Missing public contract");
|
||||||
|
const { binding } = resolveForwardedBinding(workspace, atom, iface, operation);
|
||||||
|
const planned: PlannedOperation = {
|
||||||
|
key,
|
||||||
|
atomId: atom,
|
||||||
|
interfaceRevisionId: iface,
|
||||||
|
operationId: operation,
|
||||||
|
scope: publicContract.scope === "class" ? "class" : "object",
|
||||||
|
execution: publicContract.execution ?? { kind: "remote" },
|
||||||
|
availableTargets: [],
|
||||||
|
input: publicContract.inputType,
|
||||||
|
output: publicContract.outputType,
|
||||||
|
binding,
|
||||||
|
dependencies: [],
|
||||||
|
};
|
||||||
|
operationMap.set(key, planned);
|
||||||
|
const portable = planned.execution.kind !== "remote";
|
||||||
|
if (portable) {
|
||||||
|
checkValue(planned.input, `${key}/input`, true);
|
||||||
|
checkValue(planned.output, `${key}/output`, true);
|
||||||
|
}
|
||||||
|
if (binding.kind === "state" || binding.kind === "journal" || binding.kind === "subscription")
|
||||||
|
planned.dependencies.push({ kind: "state", slot: binding.slotId });
|
||||||
|
else if (binding.kind === "edge")
|
||||||
|
planned.dependencies.push({ kind: "edge", edge: binding.edgeTypeId, projection: binding.projectionId });
|
||||||
|
else if (binding.kind === "package") {
|
||||||
|
const entry = packages.get(binding.packageRevisionId)?.exports.find((e) => e.id === binding.exportId);
|
||||||
|
if (!entry) throw new ExecutionPlanError("MISSING_EXPORT", key, "Unresolved package export");
|
||||||
|
for (const dependency of binding.dependencies) {
|
||||||
|
const port = entry.dependencyPorts.find((p) => p.id === dependency.portId)!;
|
||||||
|
const target = dependency.binding;
|
||||||
|
const via = "via" in target && target.via ? { via: target.via } : {};
|
||||||
|
if ("via" in target && target.via)
|
||||||
|
planned.dependencies.push({ kind: "edge", edge: target.via.edgeTypeId, projection: target.via.projectionId });
|
||||||
|
if (target.kind === "state") planned.dependencies.push({ kind: "state", slot: target.slotId, ...via });
|
||||||
|
else if (target.kind === "edge")
|
||||||
|
planned.dependencies.push({ kind: "edge", edge: target.edgeTypeId, projection: target.projectionId, ...via });
|
||||||
|
else if (target.kind === "interface" && port.requirement.kind === "interface") {
|
||||||
|
const selected =
|
||||||
|
port.requirement.operations ??
|
||||||
|
interfaces.get(target.interfaceRevisionId)!.members.flatMap((m) => m.operations.map((o) => o.id));
|
||||||
|
for (const targetAtom of targetAtoms(target, atom))
|
||||||
|
for (const selectedOperation of selected) {
|
||||||
|
const operation = add(targetAtom, target.interfaceRevisionId, selectedOperation);
|
||||||
|
planned.dependencies.push({ kind: "operation", operation: operation.key, port: port.id, ...via });
|
||||||
|
}
|
||||||
|
} else if (target.kind === "query") {
|
||||||
|
const query = queryMap.get(queryKey(target.packageRevisionId, target.queryId));
|
||||||
|
if (!query) throw new ExecutionPlanError("MISSING_QUERY", key, `Unresolved query port ${port.id}`);
|
||||||
|
planned.dependencies.push({
|
||||||
|
kind: "query",
|
||||||
|
query: queryKey(target.packageRevisionId, target.queryId),
|
||||||
|
port: port.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
planned.dependencies = sorted(planned.dependencies, canonicalJson);
|
||||||
|
return planned;
|
||||||
|
};
|
||||||
|
for (const conformance of workspace.conformances)
|
||||||
|
for (const entry of conformance.operationBindings)
|
||||||
|
add(conformance.atomId, conformance.interfaceRevisionId, entry.operationId);
|
||||||
|
// Check the transitive closure, retaining a dependency path for diagnostics.
|
||||||
|
for (const root of operationMap.values()) {
|
||||||
|
if (root.execution.kind === "remote") continue;
|
||||||
|
const requested = root.execution,
|
||||||
|
visited = new Set<string>();
|
||||||
|
const walk = (key: string, path: string[]) => {
|
||||||
|
if (visited.has(key)) return;
|
||||||
|
visited.add(key);
|
||||||
|
const node = operationMap.get(key)!;
|
||||||
|
if (node.execution.kind === "remote" || (requested.kind === "snapshot" && node.execution.kind !== "snapshot"))
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"NONPORTABLE_DEPENDENCY",
|
||||||
|
path.join(" -> "),
|
||||||
|
"Execution effect is not allowed by the root contract",
|
||||||
|
);
|
||||||
|
for (const dependency of node.dependencies) {
|
||||||
|
if (dependency.kind === "operation")
|
||||||
|
walk(dependency.operation, [...path, dependency.port, dependency.operation]);
|
||||||
|
if (dependency.kind === "query") {
|
||||||
|
// An admitted presentation window is not an authoritative mutation read set.
|
||||||
|
if (requested.kind === "transaction")
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"QUERY_IN_TRANSACTION",
|
||||||
|
[...path, dependency.port].join(" -> "),
|
||||||
|
"Capture explicit object references as mutation inputs; local presentation queries cannot define transaction membership",
|
||||||
|
);
|
||||||
|
const query = queryMap.get(dependency.query)!;
|
||||||
|
if (query.checked.declaration.placement?.kind !== "local")
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"REMOTE_QUERY_DEPENDENCY",
|
||||||
|
path.join(" -> "),
|
||||||
|
"Portable code requires a local query",
|
||||||
|
);
|
||||||
|
for (const operation of queryOperations(query)) walk(operation, [...path, dependency.port, operation]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(root.key, [root.key]);
|
||||||
|
}
|
||||||
|
// Additional implementation targets are opportunities, not new public requirements.
|
||||||
|
// Start with provider support and remove targets until the complete installed
|
||||||
|
// dependency closure agrees. This also handles recursive call graphs without
|
||||||
|
// mistaking an unfinished DFS for proof of availability.
|
||||||
|
for (const node of operationMap.values()) {
|
||||||
|
if (node.execution.kind === "remote") continue;
|
||||||
|
const binding = node.binding;
|
||||||
|
const provider =
|
||||||
|
binding.kind === "package"
|
||||||
|
? packages.get(binding.packageRevisionId)!.exports.find((entry) => entry.id === binding.exportId)!.execution
|
||||||
|
: {
|
||||||
|
kind: node.execution.kind,
|
||||||
|
targets: backends.filter((backend) => backend.supports(binding)).map((backend) => backend.target),
|
||||||
|
};
|
||||||
|
if (provider && provider.kind !== "remote") node.availableTargets = [...provider.targets].sort();
|
||||||
|
}
|
||||||
|
let targetsChanged = true;
|
||||||
|
while (targetsChanged) {
|
||||||
|
targetsChanged = false;
|
||||||
|
for (const node of operationMap.values()) {
|
||||||
|
const available = node.availableTargets.filter((target) =>
|
||||||
|
node.dependencies.every((dependency) => {
|
||||||
|
if (dependency.kind === "operation")
|
||||||
|
return operationMap.get(dependency.operation)!.availableTargets.includes(target);
|
||||||
|
if (dependency.kind === "query") {
|
||||||
|
const query = queryMap.get(dependency.query)!;
|
||||||
|
return (
|
||||||
|
node.execution.kind === "snapshot" &&
|
||||||
|
query.checked.declaration.placement?.kind === "local" &&
|
||||||
|
queryOperations(query).every((key) => operationMap.get(key)!.availableTargets.includes(target))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (available.length !== node.availableTargets.length) {
|
||||||
|
node.availableTargets = available;
|
||||||
|
targetsChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const node of operationMap.values())
|
||||||
|
if (node.execution.kind !== "remote" && !node.availableTargets.includes("server"))
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"MISSING_SERVER_IMPLEMENTATION",
|
||||||
|
node.key,
|
||||||
|
"Every graph operation requires an authoritative implementation across its dependency closure",
|
||||||
|
);
|
||||||
|
const plannedQueries: PlannedQuery[] = queries.map((query) => ({
|
||||||
|
id: queryKey(query.packageRevisionId, query.checked.declaration.id),
|
||||||
|
query,
|
||||||
|
operations: queryOperations(query).sort(),
|
||||||
|
admittedOperations: [],
|
||||||
|
}));
|
||||||
|
for (const query of plannedQueries) {
|
||||||
|
for (const field of query.query.fields) {
|
||||||
|
const operation = operationMap.get(publicOperationKey(field.atomId, field.interfaceRevisionId, field.getter))!;
|
||||||
|
const effect = query.query.checked.effects.find(
|
||||||
|
(effect) => effect.interfaceRevisionId === field.interfaceRevisionId && effect.memberId === field.memberId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
operation.execution.kind === "remote" &&
|
||||||
|
(query.query.checked.declaration.placement?.kind === "replica" || effect?.uses.some((use) => use !== "select"))
|
||||||
|
)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"QUERY_REMOTE_MEMBERSHIP_UNSUPPORTED",
|
||||||
|
query.id,
|
||||||
|
"Remote getters may enrich selected values after native snapshot closure; they cannot determine membership, order or canonical-input admission. Expose an accepted canonical value or split the service result into a separate query.",
|
||||||
|
);
|
||||||
|
const member = interfaces.get(field.interfaceRevisionId)?.members.find((member) => member.id === field.memberId);
|
||||||
|
if (member?.kind === "relationship" && operation.binding.kind !== "edge")
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"QUERY_NON_NATIVE_RELATION",
|
||||||
|
query.id,
|
||||||
|
"This query runtime traverses native graph edges. Expose an edge relationship, or return a computed service value through a value getter.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const unsupported = unsupportedQueryOperator(query.query.checked);
|
||||||
|
if (unsupported)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"QUERY_OPERATOR_UNSUPPORTED",
|
||||||
|
query.id,
|
||||||
|
`This execution world does not support ${unsupported}. Use a bounded portable getter over admitted inputs, or a server service returning the computed value. Query placement will not change automatically.`,
|
||||||
|
);
|
||||||
|
const placement = query.query.checked.declaration.placement;
|
||||||
|
if (placement?.kind !== "local") continue;
|
||||||
|
const source = plannedQueries.find(
|
||||||
|
(q) => q.id === queryKey(query.query.packageRevisionId, placement.sourceQueryId),
|
||||||
|
);
|
||||||
|
if (!source || source.query.checked.declaration.placement?.kind !== "replica")
|
||||||
|
throw new ExecutionPlanError("MISSING_REPLICA_SOURCE", query.id, "Local query source must be a replica query");
|
||||||
|
const localRoot = query.query.checked.declaration.root,
|
||||||
|
sourceRoot = source.query.checked.declaration.root;
|
||||||
|
if (localRoot.kind !== sourceRoot.kind || localRoot.interface !== sourceRoot.interface)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"REPLICA_ROOT_MISMATCH",
|
||||||
|
query.id,
|
||||||
|
"Local and replica queries must use the same root contract",
|
||||||
|
);
|
||||||
|
if (localRoot.kind === "workspace-interface") {
|
||||||
|
const sourceWindows = source.query.checked.selection
|
||||||
|
.flatMap((root) => root.selection)
|
||||||
|
.filter((s) => s.name === "objects");
|
||||||
|
for (const selection of query.query.checked.selection
|
||||||
|
.flatMap((root) => root.selection)
|
||||||
|
.filter((s) => s.name === "objects")) {
|
||||||
|
if (
|
||||||
|
!sourceWindows.some(
|
||||||
|
(window) =>
|
||||||
|
canonicalJson({ arguments: window.arguments, conditions: window.conditions }) ===
|
||||||
|
canonicalJson({ arguments: selection.arguments, conditions: selection.conditions }),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"REPLICA_WINDOW_MISMATCH",
|
||||||
|
query.id,
|
||||||
|
"Local objects must observe the identical confirmed source window; use availableObjects for a local candidate view",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const traversals = (linked: LinkedQuery) => {
|
||||||
|
const paths = new Map<string, { complete: boolean; requiresComplete: boolean; windows: Set<string> }>();
|
||||||
|
const entry = (key: string) => {
|
||||||
|
let value = paths.get(key);
|
||||||
|
if (!value) {
|
||||||
|
value = { complete: false, requiresComplete: false, windows: new Set() };
|
||||||
|
paths.set(key, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const visit = (selections: LinkedQuery["checked"]["selection"], parents: string[]) => {
|
||||||
|
for (const selection of selections) {
|
||||||
|
for (const stage of selection.relational?.stages ?? []) {
|
||||||
|
if (stage.operation.kind === "source")
|
||||||
|
entry(JSON.stringify([...parents, stage.operation.memberId])).requiresComplete = true;
|
||||||
|
}
|
||||||
|
const member = selection.interfaceRevisionId
|
||||||
|
? interfaces.get(selection.interfaceRevisionId)?.members.find((m) => m.id === selection.memberId)
|
||||||
|
: undefined;
|
||||||
|
const path = member?.kind === "relationship" ? [...parents, member.id] : parents;
|
||||||
|
if (member?.kind === "relationship") {
|
||||||
|
const many = member.cardinality === "many" || member.cardinality === "many-unique";
|
||||||
|
const complete = !selection.predicate && (!many || !!selection.arguments.all);
|
||||||
|
const key = JSON.stringify(path);
|
||||||
|
const value = entry(key);
|
||||||
|
value.complete ||= complete;
|
||||||
|
if (selection.arguments.first)
|
||||||
|
value.windows.add(canonicalJson({ arguments: selection.arguments, conditions: selection.conditions }));
|
||||||
|
else value.requiresComplete = true;
|
||||||
|
}
|
||||||
|
visit(selection.selection, path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(linked.checked.selection, []);
|
||||||
|
return paths;
|
||||||
|
};
|
||||||
|
const admittedTraversals = traversals(source.query);
|
||||||
|
for (const [traversal, needed] of traversals(query.query)) {
|
||||||
|
if (!admittedTraversals.has(traversal))
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"REPLICA_TRAVERSAL_MISSING",
|
||||||
|
query.id,
|
||||||
|
`Replica source does not admit relationship path ${traversal}`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!admittedTraversals.get(traversal)!.complete &&
|
||||||
|
(needed.requiresComplete ||
|
||||||
|
!needed.windows.size ||
|
||||||
|
[...needed.windows].some((window) => !admittedTraversals.get(traversal)!.windows.has(window)))
|
||||||
|
)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"REPLICA_RELATION_INCOMPLETE",
|
||||||
|
query.id,
|
||||||
|
`Local relationship ${traversal} needs either complete source candidates or the identical confirmed first window; a window cannot supply unseen matches`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The admission executor follows these canonical dependencies on the admitted roots.
|
||||||
|
// They do not change source candidate membership or make local take/filters authoritative.
|
||||||
|
source.admittedOperations.push(...query.operations);
|
||||||
|
for (const operation of query.operations) {
|
||||||
|
const node = operationMap.get(operation)!;
|
||||||
|
if (node.execution.kind !== "snapshot" || !node.availableTargets.some((target) => target !== "server"))
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"NONPORTABLE_QUERY_FIELD",
|
||||||
|
`${query.id}/${operation}`,
|
||||||
|
"Local query getter needs an installed local snapshot implementation",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const query of plannedQueries) query.admittedOperations = [...new Set(query.admittedOperations)].sort();
|
||||||
|
const schema: BindingSchema = {
|
||||||
|
format: "quixos-bindings",
|
||||||
|
version: 1,
|
||||||
|
interfaces: sorted(
|
||||||
|
workspace.interfaceImports.filter((i) => !i.template),
|
||||||
|
(i) => i.revisionId,
|
||||||
|
),
|
||||||
|
interfaceTemplates: sorted(
|
||||||
|
workspace.interfaceImports.filter((i) => i.template),
|
||||||
|
(i) => i.revisionId,
|
||||||
|
),
|
||||||
|
packages: sorted(workspace.packageImports, (p) => p.revisionId),
|
||||||
|
};
|
||||||
|
const normalizedSchema = JSON.parse(canonicalJson(schema)) as BindingSchema;
|
||||||
|
const buildContexts = packageBuildContexts(normalizedSchema);
|
||||||
|
const definition: ExecutionPlanDefinition = {
|
||||||
|
codec: "camino-execution-plan-v1",
|
||||||
|
workspace,
|
||||||
|
bindingSchema: normalizedSchema,
|
||||||
|
...(workspace.presentations?.length ? { presentations: sorted(workspace.presentations, (p) => p.id) } : {}),
|
||||||
|
packages: sorted(workspace.packageImports, (p) => p.revisionId).map((p) => {
|
||||||
|
const contract = buildContexts[p.revisionId]!.contract;
|
||||||
|
return {
|
||||||
|
...contract.definition.package,
|
||||||
|
source: p.source,
|
||||||
|
sourceDigest: hexDigest({ revision: p.revisionId, source: p.source }),
|
||||||
|
bindingDigest: contract.digest,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
owners: sorted(workspace.conformances, (c) => c.id ?? "").map((c) => {
|
||||||
|
if (!c.id)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"OWNER_ID_REQUIRED",
|
||||||
|
`${c.atomId}/${c.interfaceRevisionId}`,
|
||||||
|
"Installed state owners require explicit stable identities",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
atom: c.atomId,
|
||||||
|
interface: c.interfaceRevisionId,
|
||||||
|
initialization: c.initialization ?? [],
|
||||||
|
storage: c.privateAttachments,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
operations: sorted([...operationMap.values()], (o) => o.key),
|
||||||
|
queries: sorted(plannedQueries, (q) => q.id),
|
||||||
|
profiles: sorted(
|
||||||
|
attachments.filter((a) => a.kind === "state" && a.storagePolicy.kind === "crdt-document"),
|
||||||
|
(a) => a.id,
|
||||||
|
).map((a) => ({ slot: a.id, profile: "camino-automerge-3.4.1-structured-v1" })),
|
||||||
|
versions: { ...executionVersions },
|
||||||
|
};
|
||||||
|
const normalized = JSON.parse(canonicalJson(definition)) as ExecutionPlanDefinition;
|
||||||
|
return { definition: normalized, digest: hexDigest(normalized) };
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user