mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: prevent externally supervised state schema drift (#121069)
* fix: prevent externally supervised state schema drift * refactor: isolate schema ownership support code * test: follow canonical additive column order * test: keep older schema fixture valid * fix: preserve additive schema compatibility * chore: remove release-owned changelog entry * test: follow schema compatibility owner * style: format schema compatibility test * refactor: split sqlite schema sql helpers * fix: preserve desktop schema compatibility * fix: preserve ownership gates across platforms * fix: preserve detached updater long paths * fix: close external state ownership races * chore: refresh plugin sdk api baseline
This commit is contained in:
committed by
GitHub
parent
bff0494f33
commit
f31d9d8fa9
@@ -3,22 +3,22 @@ a592581a61518734a8a410ec9f71069529ee5fbb54848daf650199eb1cb557ca module/account
|
||||
71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id
|
||||
64fc61ddb3ed2367ad193c8174e5ddf4f563d7456acd44ab3529219a8d0776fb module/account-resolution
|
||||
4fbb1c87e99399f842a20d75d5e35a4b7064a1b7f02115c23f9a2a7cdcfb57ee module/agent-config-primitives
|
||||
dc5f4ba23a5831a5f9619049c6f87bdc8d2b9cd272025653750ee84ec0f8b222 module/agent-harness
|
||||
d0ee7a77ac65b3670dcd5526678e13bc966b76def43137445e377282085ed95e module/agent-harness-runtime
|
||||
fc111182606f850b49842fc67ef7c0176aa1ca57299e8573e872e35612b719cd module/agent-harness
|
||||
2b824eb3e9ae741ed5e7f705e6153acf642f022bc288a55b82871175f6b1f16a module/agent-harness-runtime
|
||||
762a2c3df44621e9464fdd3ef437be2a878bfa9a7e8ca7670f88ae549e601614 module/agent-media-payload
|
||||
eca72eae4704a6828e07dbcd5e8618c409187324067578e519e7569a03fa6122 module/agent-runtime
|
||||
e6aa1ac9f4d951fc08b6848491e516486caee62516b6037cfa60087a0bb52e2c module/agent-runtime
|
||||
66cad8b985017a2487e47d6794dede2d16348f717e7f445cd5df4af2eb7f95f4 module/agent-scope-runtime
|
||||
8fecb210e22bce4532b6ab649b09465f0bd2c857a44abf40db7d683d6491e6da module/allow-from
|
||||
f0cd21c1854e209363fc2cbc684ba9dee9668ba7b5d0b57a96d4c8da825d7337 module/allowlist-config-edit
|
||||
6a76c827ecd4c7f66a9b93533849572d5211da2090a4121758f5a26450a8d77c module/approval-auth-runtime
|
||||
9765ea0869a5b0b84b97617e82c93f2bf7a1a33df03cfe38e661657c746069c8 module/approval-client-runtime
|
||||
6b6611999dacdb86603d98b741526b337eed58e7d4a479b71aa849339f71845f module/approval-delivery-runtime
|
||||
a2fcf1f9e5f0605a239a8511f76a9e37c0b61c50bb944e46ca1eeea848a7d364 module/approval-auth-runtime
|
||||
bce8b0628bf4aeb8bac4844b05550288b43591e0fc6971b5b3c15b028c6d0ea6 module/approval-client-runtime
|
||||
70931c3d8b1e14648ee02059bb8198601725d1be26875c5f82c0d232a3a43b3f module/approval-delivery-runtime
|
||||
afba69ce95b3a939511c1f1e6b27a5999e8793304cba7578b0785c7edc342580 module/approval-gateway-runtime
|
||||
a9f10f7c70287d7dc8ad224ca6424234652e74908ae7de2fc7edf4c98c91de4a module/approval-handler-adapter-runtime
|
||||
f8b5c31c956b567827d58e9a1f6493846448b3c0ae30904cc7f4d872b8071d26 module/approval-handler-runtime
|
||||
cb52e36a0de53874de5115ec797d39e576533cd3dda576c9b6c57af4f2888dca module/approval-native-runtime
|
||||
2485cc4f41d673a7f285ddcc19313897188e7f21a3ab007904fa333ac88f1866 module/approval-native-runtime
|
||||
b102781a2d78bbfafcf4205aeb2c169ad69bc4010edaf376647036cf3fba48d7 module/approval-reply-runtime
|
||||
c93d529d020ac1ee52c230e0883f9880656908e8404ef96251bcf7c93afd39f4 module/approval-runtime
|
||||
c029db9ccff790a9c1065dcd4c478dce7c3c92949996e999b6b0965c9403733f module/approval-runtime
|
||||
01ca912836b8dec672f705e294f72d346e778557e4c591317d67558ea7669c0b module/archive
|
||||
d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean-param
|
||||
076689cd8e62c842580d61a34d97bcf42c90406287d428fc5d0d3eb373ec274d module/channel-actions
|
||||
@@ -26,57 +26,57 @@ d1341161e2f5d44eaf7eca3a777ab3f106c7db1db32778bf98c75c7fa7d57ca4 module/channel
|
||||
c2cc71d5070b6071c51248b0648d1ad1a9468d3737df890adc77ec02025e8853 module/channel-config-primitives
|
||||
9fa7e5f9b3515f0bbc252210b034d39c25de9eb431def30dddce3198c309efac module/channel-config-schema
|
||||
a6af4971aa36345f1a32d97981bd36cc29794f62f30bd57859df2816c65f6fde module/channel-contract
|
||||
f72c233f06b911825496edb619dfd76fe33e0a52172f2dbf8d5b79969edb983f module/channel-core
|
||||
f1f206b2b9d4283be0aabc13a05008fd8e4121d85eb540d67379159e157e4e76 module/channel-core
|
||||
40f8e726de245e17cf7bf908bb7d6fb084f4927f5ccc20b0c089f47af1bd7a0e module/channel-dm-policy
|
||||
b76423e8c53a0fa835dd3d633edcb147b8cdc3a3e6a9ad7a3b5e54bbbdf2649b module/channel-entry-contract
|
||||
45fd5148ed2ca6b6fdcf0c244e2b51a2fe4d5b6e4d1c3d9875c0e07a31928b12 module/channel-entry-contract
|
||||
56b185eb99eb4981056befd38cd9511b655923934acb1c22aeec7d9c504bdf96 module/channel-feedback
|
||||
de6f1079345b0e161eaa306b9f59aa33cbf5b7858bccc6c61e946800d6c00472 module/channel-inbound
|
||||
23673efb723395ee091ba21a986c7b295fc794875c509eb04aa43e06c503bca5 module/channel-inbound
|
||||
780abefd1600c55da219da43c769c873d071ab616b5e02d22f365677ed5a7be4 module/channel-inbound-debounce
|
||||
b72485da4fe5d7f73352f4474d00e30b749b64c8a2066755e104958cd96036c2 module/channel-ingress-runtime
|
||||
f83762680c0d0aa6fa1bad9e53c70bfdf3694126dbdd9491db764a69ed3f6d67 module/channel-lifecycle
|
||||
0e47457e38d1df0bd572e1408cde2ca6a788b65205f43c585316b5ad3a8f2f16 module/channel-logging
|
||||
3f0c67dd5b9d53bdec6c7c255228e3579f1746c014b1212b361fbc10c97af7b3 module/channel-message
|
||||
afc036e15843f795bb295c6d9d4e378cca6d816c29c613ecfb49d47353fc8f3a module/channel-outbound
|
||||
5e0a4e78fa4877753e944a24afcb52bc18b84a99b56f26550ee83e311eae54bd module/channel-pairing
|
||||
024aff38b066e93618c36a0ecf825cb4e5ab6de5c4b9b3a4030c27f8252a1ada module/channel-plugin-common
|
||||
857f5a19fef9eb04e8edb5e04e4977afed577c7c0a1e533a8c41d85d359568c5 module/channel-message
|
||||
570cdc296476b52d0cc305ca1044a03e23e5a38deaa37cd91607ffc632649eb0 module/channel-outbound
|
||||
72a5d7dc13f8eb7a65159e2f8619c17ef1bf55fdff0a05e9d23c285cef0d32fa module/channel-pairing
|
||||
59bcea05361cae2d9f8bc9f1fb70c51eac9761a63826426101271c8b603c3506 module/channel-plugin-common
|
||||
c2c7900e47c0595bbc8a166aa60cfbc5a0e02f9a4c0985d5fd630c7dec4c3dca module/channel-policy
|
||||
fde35c74c5db0f89b235210a1eca1b4e0712181c456f24793958fa73ee0ce042 module/channel-reply-pipeline
|
||||
e787a315cec9681a49a9e5d1af8fe4fae345561d30996888e5ef6593faae0a75 module/channel-reply-pipeline
|
||||
482370e60135db9bfaf07f24bab549e5fde09ab265a6061a1f587c5d93929e91 module/channel-runtime-context
|
||||
32c2e9ea4f954a6b04d6e61b392e63d42b5cc74ccbdc1404c94a4c97463f0ca1 module/channel-secret-basic-runtime
|
||||
941673d941b3ab9bd553ba9153305af7933019254a504a603907a237d06b5e79 module/channel-secret-runtime
|
||||
4647a0e849e3aa193af751c71d1b137947887a489b999043618bfaebb1a37002 module/channel-send-result
|
||||
7f5bc35b328c716022a442804f3e83416ee23eaddb49e771c385660dc6540612 module/channel-send-result
|
||||
4493cf54a54d7159e27d33884b1cd231cd54a038a85a3d5d3676e149954444a7 module/channel-setup
|
||||
66df387345c0d64083904f4271228bb1e5660e40b00f42afc5d802d3ff07627f module/channel-status
|
||||
198ed5042f86da7731dad61bc66f44bcb249fd9e369d79cf4509df172d5cab7f module/channel-streaming
|
||||
67df67da5ae72e9eaeb19d41b6bd2432ec4fd8b7b63b2b616fb98f3b4e0ec41d module/channel-streaming-config
|
||||
fdeffe356c7c4edeec9f8fd03edcadc375eabc7a9412e582b10c3180e3ef40fc module/cli-argv
|
||||
ad12670dbfe538f8d0ebf4fb2b68080e93a760278278e6b1ce9bb129d4b2d533 module/collection-runtime
|
||||
4f410c7170f48dff594d3ba06636289cc85295e294f76dd92c14a41f3a699d64 module/command-auth
|
||||
0bc3a0265e7cc89e47470386571fd7bb1ff4baf7754edf2155556de6df824f2f module/command-auth-native
|
||||
575158115f2a8528a61e5765781534dda5eee8729b61dd409819dcabd68c1ab4 module/command-auth
|
||||
b908f15287dc66d3e28a8092eeeffe4bfd3ce2956acabf660c306b213e7185fb module/command-auth-native
|
||||
396f60be14d8fe1144076cffeafc2e2ddc0379b5e664f9fd45d13a0edc4a9f2f module/command-detection
|
||||
5155909167a810ca258ec9baf4814e7d495cf25bd287fbb22be55eb1a8d88dfe module/command-primitives-runtime
|
||||
0ab02df5c3904386bfb062e7dce0a24e2ed835b54dcc0112e367346624cb0efe module/command-status
|
||||
02011f70a5de5f1861609206038b101d3e9bbf39bf45a3cfaaae56596299225b module/config-contracts
|
||||
7ee5bc35d30165be33ab229a96dd6a6e3e04c620fa4c41753d568798c514feb3 module/config-mutation
|
||||
08beaa7b5b8797bf248068c73332847b196c5ce47a74c67e788b3281aea46a97 module/config-runtime
|
||||
eac10f08466e8513c5b5663b29ca3d4ca76d66858367163c5239f08f33d7024c module/config-runtime
|
||||
069ca892c9d9c0d82f9a0d1e72970da2b1bc5d3c4fc15e127b18bbf78292cd2a module/conversation-runtime
|
||||
ef1c0830cfd2fccec7388acdba7d88c3352657e617e5da5291f749233942b095 module/core
|
||||
943df87a9d7c71a60b588da8c069eadc29cd1dd9d8aafd6bdf558225719f8fa8 module/dedupe-runtime
|
||||
5c1df24349f58e2f25b42ec4ef79d443d84a7bfeb72b8d3596ad339333dda27a module/core
|
||||
286659baeb922f9a681d172cb1b69b9fb9e09ada87fd2076d90be684b2333cb7 module/dedupe-runtime
|
||||
ebef0e650ab45e44c9335e2b3e15588c968cea6dadd125364a076f9c50ad1e8c module/device-bootstrap
|
||||
9ecd22bc3acae3f9b0556b305c4232abe54a7993b910caa789cba6cf917dd1e2 module/diagnostic-runtime
|
||||
6d90412b97efcc675d16acc3aeb2752520a613ce4bb25b87e47f84fd6b36736d module/directory-runtime
|
||||
2c859542e09d467fd6ac2d46d5c3e79a2d707bc517500901c4f8c65f244dd565 module/discord
|
||||
0a933f3b872c2e8944f962f5229b98c33b69d79044084d64060dac338e59ed57 module/discord
|
||||
f2d69888d0c799e12d97b5f92393906aa843be25f61cb6a6c55d32f1db800a44 module/error-runtime
|
||||
1caf8cc5552ca5e392599457e3f9616e033de199d9f47c12320cdee617a6b0e9 module/extension-shared
|
||||
dd9f6e0fd33cc88b22543c1ee30cc09cf4de4d8f30dff7b7f9cebef885c21543 module/gateway-method-runtime
|
||||
7919b19db8bf80043ac2fd10d7cdadcc3ec5367752cc6cbe7d0f5b61886276bb module/gateway-runtime
|
||||
f558a90b52bda7e431cc46005f8735263ee065942925f9d547ae528f218be479 module/gateway-runtime
|
||||
575656e5e0195c8d1813a4e2e3a271e800bb97d44f2dd78c242e3b6714ffa097 module/group-access
|
||||
77726dc396a269ca62fe958c4c797de54d94aba47b7b166e0b66052b604c4b93 module/health
|
||||
74252b820ea1d21457f01a4e3b868233737e83b134f3d8b8cecfb2392a9647e7 module/hook-runtime
|
||||
53e032e2f3a2f434702ce1633b0d2a0bed5cbe5f6ef01d7dc348967395036b9a module/hook-runtime
|
||||
4d9f7d3c3e59113b493f3cac812b3c117968035af10a751a92f2aca8e2ec8806 module/inbound-envelope
|
||||
4928af5d2509f696b896f53ac790303a0742202dbcdae3e44fe6d1b434a9c1ba module/inbound-event-delivery
|
||||
785de60e1b38da50cb409722b93b6fbe6ecb9ddf0e6fdecf1024b93c58fcf930 module/inbound-reply-dispatch
|
||||
7f75e1166081708656b561095753ab05480265a55bc40a8e5ca34e221a4798e2 module/infra-runtime
|
||||
ae8df9d1b4be308d8851a3555cdcafca95598d03788d7b82c114b34559e2b68e module/inbound-reply-dispatch
|
||||
97a718b5c0f276c7cb0feab83e5aa2bb0f9a14175236d94d4c9f7a7baeb05137 module/infra-runtime
|
||||
ce73721421f1b903dd04ead4df173582e59ea3e9990248102c448b419cc6d272 module/ingress-effect-once
|
||||
2fdc37835ccc4651c580847250adf8e42c165483eb239907aea981a6552e24b4 module/interactive-runtime
|
||||
408d257ab5cc4b88a22b7e7595039cb8fc524b261c44141b294fbd0100ba62ee module/json-store
|
||||
@@ -85,32 +85,32 @@ e5acf130491ef188b193a3e3dc5284e53a29f7e08278caa188f5ddb96e860dc4 module/logging
|
||||
f1ca4ced4305d0769c2d8cc1291137ac7002fe0e6eaec2c1a71edad2204c8311 module/matrix
|
||||
8cef237e683b323062fa4134a88a0b5b8ff97f1c5d356654c8062fdbf5eed87c module/media-local-roots
|
||||
f74d7295fe716aa140aa0bc9300d6259d71dab826de0808fca6bb02592bf5d6e module/media-mime
|
||||
d8e039f3ebb4c4bf7e30217105e2aae84934f73df9e05be27db4f746ab45f1a9 module/media-runtime
|
||||
d0c103832eac668fbbd1593c1c9e3ee77b4233f4b336a6d94cd2f1b032c3050f module/media-runtime
|
||||
6a52f93107335f88751704352cc01e62add06f854a5b7d765e2a5ee87c0313b6 module/media-store
|
||||
6e5b09a983a69041873ece5bc3b719c0dc913e5a69e2afdf6c7958889e711097 module/media-understanding
|
||||
7f74e947cca0cf894869bede29815863d5eacca016d04d6262e9d41b3999ae94 module/media-understanding-runtime
|
||||
4284e7bab8b48c14c4950f703f6b4fc3e0a23164086e5773b41cb35e1d23db99 module/meeting-runtime
|
||||
6be577c5c6420114b85857648071d176ab34fb3bf696c06937f3fe6fccaf1119 module/meeting-runtime
|
||||
e5d2b540090c17602a1c36b42559f7b516e20730e06bbdd8606597c66ef2ef95 module/memory-core-host-engine-foundation
|
||||
58b06d2d025e63e852270e871dbd8bc2c308fbfa815d2b31cb7330a27302a94c module/memory-host-core
|
||||
4522c5673785bc0209178f452ac982fbb68937d5f530a6425ce71c7cd2fff244 module/memory-host-core
|
||||
1efa0aadc4261d1c6073058cbf3dcc9fa681424819bdd14333e19b249bbc4b18 module/messaging-targets
|
||||
3b35256be44a7f2f69a1c2ebb33b1028d6bdc8c714a426f3b466a61056c5c5d7 module/model-session-runtime
|
||||
27d34ee308fa91484d6fa40190b205b1e139a9aaf310850c1e8a5b9912ae7b2e module/models-provider-runtime
|
||||
577d82ef09e96de6af83a22c9b89cc1ddf2d76964abac5808b102ce04ced294f module/model-session-runtime
|
||||
e775d34923ab24ed4be8bf60b115e6869a9cd5bfca4d8eb9324c5b67209edce9 module/models-provider-runtime
|
||||
b518b4caa7f138d448fe08f5fc9e5f67da5d4b26aafaee2370feb2300e1baa21 module/native-command-config-runtime
|
||||
1e8b5e7bd8234919d339e750368cbe6425e7463d7e63eb5200d728cc17d6e959 module/native-command-registry
|
||||
15d1d490e4f7909d2d563b83988258a9cc5fa1344cffb0cd3dcd7592c0467632 module/param-readers
|
||||
ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persistent-dedupe
|
||||
f8b250da3eab2f85b2bf358872f910b29e541ea04358498a6deab353fa0520fc module/plugin-config-runtime
|
||||
63aa620ad90d217e31bb55f448cbdfacf823dd17c1b1005baf5f8122b350d555 module/plugin-entry
|
||||
a8e736985eb018d9f7545b94875cbb4c1dc4c351ecd3142faf72f5ebfe4f66e7 module/plugin-runtime
|
||||
e70beb5db3ada1eb416ffc97240c81b3a8b5b8e09ea974b18bc6a7c00904267f module/provider-auth
|
||||
04183b53b3e6ad09347ac7b63f700cf81edaecfe29e3782cf467f53349fa7930 module/provider-catalog-runtime
|
||||
c07a21684ee72ef37f8b5e8ac52c7b6a8309297e3d1c46c69e748edda80cfe9c module/plugin-entry
|
||||
84a52fdffc9c9f9f7eb767186bbd3b4c4e34ecd511e10e63c9b4a9226918557c module/plugin-runtime
|
||||
a07195b83ca9bef78170bf21cb62996c078c8a1670556d753067fac360d4eac3 module/provider-auth
|
||||
6c79117a067db2ae467bc9de9e13601f5c65b96fae1cafe73e73644bd866ed97 module/provider-catalog-runtime
|
||||
8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture
|
||||
913c32adcd94ea9c8b7efac0f7c65231500c49129e638c0682eb632da3d5a0c8 module/question-gateway-runtime
|
||||
f319c52a0f1655de752efa55a983893e56e4afa0727f6cc4896c3238c984e91c module/reply-chunking
|
||||
2c2e5df7b8ece32b837f152a4aff60a8c674a6b65b62c8c8f5af3afd128a43fe module/reply-dispatch-runtime
|
||||
43562c8fd0c976227051da084698c9aab757a769685b9630cbda8b8cc4cdbb16 module/reply-dispatch-runtime
|
||||
73f861fa3179d5af1159853c5acab0eec7a6c8f9398dcb75ea770e784fca6727 module/reply-history
|
||||
ecc6e3190892ff416ce69d26c8ce0aa08a468c8689ca3606c18b0271e2d87597 module/reply-payload
|
||||
cf0283a960539456c02488b19e77eb789598e0f3a0abd59b215d6b402dad4079 module/reply-runtime
|
||||
2622e09ff245d5813d305ccb2aadfe485d503484172da9a903508bc4edb344c7 module/reply-runtime
|
||||
aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-walk
|
||||
292405cda407e2569f731212f6f981b12bfe682836d5f6e58c6ae194ba27f144 module/routing
|
||||
7877a7e58fa32a64107154e5b714c6d165e96989d4aa5f43e0afac085a187af0 module/run-command
|
||||
@@ -118,20 +118,20 @@ aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-wa
|
||||
f7900c5ff5827e1b53a926367d807bd4c81b313ab74fd0409d571ae881226a60 module/runtime-config-snapshot
|
||||
8e33ccbbe610c6c3ad04a140f7511a16cf406733c8459216b1fb4b1e7f0208d9 module/runtime-env
|
||||
7e871b7319745678bb83fcfc1b54c8751b0ab1af92ff06c01d0659ac92863c11 module/runtime-group-policy
|
||||
b7d824cbec83bf90000c941ed96a4f2ebc298cd21b395fea08004d9f171ac1d4 module/runtime-store
|
||||
e70b9ecd6750fab9769152846c9807a35b4a19f52419eff8b411140ac45328b3 module/runtime-store
|
||||
d17862c40825af1ddf0257b44f1e1cbb9c375e8e5ed668fae75d530d1a465cf9 module/secret-file
|
||||
8e2ac4d3973d8d8ce4478e3440d66ee5c0d9213b0fe9e927c421d14fd31e5e86 module/secret-input
|
||||
5958846179128ea6b29135fb90ce4e40b8df7de63d1c65d1696e52294cbc0f52 module/secret-input-runtime
|
||||
72ca4634e0038ad6dc80c4ce12eb34014e1fa990c7eabe19bb4501b2722ee685 module/secret-ref-runtime
|
||||
6db2af355b0d1d46f493a5d34a63a6ea5231dafbd8b896d976a52da681868f67 module/security-runtime
|
||||
1198f77ee999aecc74f6b033131922dadcab05be774d7893635a24d456b3681b module/session-catalog
|
||||
faad33f1fb9314e36ec58c2c675cd277d04e1c41554e4cb80a38a79776976f68 module/session-discussion
|
||||
cd31b3430b196d78604de2682802f95453ce8e32c2222ae5f94241e422a2e1e6 module/session-store-runtime
|
||||
3f883b7b347154781a94b24af122c3dcce5dca4992b98cec346d26ec77732a7b module/session-catalog
|
||||
7340c857eec2a09c2abe0fb39c20abd3896c0ddb6ceac7109fa017c7e7d1b592 module/session-discussion
|
||||
bbfe5fe057d23fd874689776aff2d9aa506877c683e275ee66fa28fd866fc58e module/session-store-runtime
|
||||
e54f5c86a55683028a8c4a7c821cf9b9f11b48f91ae9b08e79f8e94b91eb5fde module/setup
|
||||
41c4790efda179c600fcc8cc4f2cd9c0505efd86df15a1a840aeb99c839bc627 module/setup-runtime
|
||||
44d37e0d9131ad2859f41068f2604090c784e65f1bd6ebda8e051b6f2e5e1660 module/setup-tools
|
||||
dc901275501e473a69a8e9435e1c95096c47f1bb569cf3240c1e82c769fffaa0 module/skill-commands-runtime
|
||||
623e70e5fa77936c6a15ca3a0ae5cf47713e80ef47525a00fa18c3629ff71fde module/speech-settings
|
||||
9c36f9dd90bee4b1fd51812862d835b54377bfbe4b9b9387d33ed35863eb924b module/skill-commands-runtime
|
||||
5f0662802cdf24dd68d09e6fa46c4502580b48499d0c2bdfbf134fc1431e2551 module/speech-settings
|
||||
1e4df0f48b50a8eb546459af1e947ba143c287e3d9c13706c45b67d411c46ad5 module/ssrf-policy
|
||||
e4940099376e18e34c234d4d90d836cbe234c3e891e40b7b4879168b386b1121 module/ssrf-runtime
|
||||
3c3c812d2d0c997ec81d117346347b802a3445e8e0d76aa6d0e3259f3cf55f98 module/state-paths
|
||||
@@ -141,11 +141,11 @@ f097d0096b21c8a052f0f649b7512ecf2aba4744ae6956f001950e053828b309 module/string-
|
||||
aef35bee2502cd6ed8765409b758e452aff8ac9469fd773e6a2a44c9a1bc3f66 module/temp-path
|
||||
87fa81b9e58d8fc04a4b4202d2d37fca339615f5225687d9db905151439e0f4d module/text-chunking
|
||||
50d2fa56b2ad57edd12d74201d18ab43045b5c9da0ab98cc158cebe9bd8b768b module/text-runtime
|
||||
6a10eaa7c058895f47b47a578f7a055deb065b8215e794203a099fe5d8ce1cf1 module/tool-plugin
|
||||
f629a2045739ca947fbed8ff7b10e884449b3031443487f0aac83ebccb450a41 module/tool-plugin
|
||||
dc1a073c59ab61e2789533b777b3f0cb9af689d64a97796b10e8aa82552510db module/tool-results
|
||||
5d4f1dbde1238799e5244b9c0b799aa2fb7d607d0c072d9a70659978825ef4ee module/tool-send
|
||||
cda105b721d498df23a554c6b68be150b8fe66b8b9172185c31a0b3b0646b1dc module/web-media
|
||||
13e687addfc0a57760ab390928bbd5c38bd185f0ddf7b511033b76a41c7e3b16 module/webhook-ingress
|
||||
fe6d60775a9db35b9ad0470899e3174225d65f8d8b052b60e81b0a49bf28fc97 module/webhook-ingress
|
||||
ddd05cb74246c393fef4d3fd8331eddd4db72a000fe45e9e515357b757636854 module/webhook-request-guards
|
||||
de59e86e126b75d13251cba7ebbe27b44d9b5588785d98df5ff4d6722374c81f module/widget-html
|
||||
9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod
|
||||
|
||||
@@ -160,6 +160,19 @@ Set `OPENCLAW_SUPERVISOR_MODE=external` only when another process manager owns t
|
||||
- OpenClaw self-update is refused so the supervisor can stop the Gateway, replace and finalize the runtime, and restart it safely.
|
||||
- A fresh-process restart writes a bounded SQLite handoff before clean exit. If persistence fails, the Gateway falls back to an in-process restart instead of exiting without a consumable handoff.
|
||||
|
||||
An external supervisor can also claim durable ownership of shared-state writes:
|
||||
|
||||
```bash
|
||||
OPENCLAW_SUPERVISOR_MODE=external \
|
||||
openclaw database ownership claim --manager gateway-supervisor --json
|
||||
```
|
||||
|
||||
Before claiming, stop and verify every older Gateway, CLI, Doctor, updater, and native app process that can write the shared state database. Pre-contract processes do not understand the ownership row and cannot be retroactively fenced. Claim only after every remaining writer uses ownership-aware code and carries `OPENCLAW_SUPERVISOR_MODE=external`.
|
||||
|
||||
The claim is idempotent for the same stable manager identifier and refuses a different manager. There is no automatic claim or unclaim path. Once claimed, unmarked writable shared-state opens fail before permissions, schema migration, additive repair, compaction, or other mutation. Read-only access remains available. This is protection against accidental unmarked same-user writers, not an authentication or lease protocol.
|
||||
|
||||
For upgrades and rollbacks, have the supervisor create a consolidated WAL-consistent copied snapshot with no SQLite sidecars, then run the target release's own `openclaw database preflight <copied-state.sqlite> --json` before activation. Numeric schema versions alone do not prove that a same-version additive shape is compatible. See [Database schemas](/reference/database-schemas).
|
||||
|
||||
`OPENCLAW_SERVICE_REPAIR_POLICY=external` remains a separate Doctor repair policy. It does not declare runtime ownership; supervisors that need both behaviors should set both variables.
|
||||
|
||||
External supervisors can negotiate and consume restart handoffs through the hidden machine contract:
|
||||
|
||||
@@ -29,8 +29,30 @@ OpenClaw applies forward-only migrations when it opens an older supported databa
|
||||
|
||||
Changes may stay at the same schema version only when downgraded readers remain safe. New tables qualify because older builds ignore them. An explicitly compatible column on an existing table qualifies only when its declaration is exactly one bare nullable SQLite `STRICT` datatype: `ANY`, `BLOB`, `INT`, `INTEGER`, `REAL`, or `TEXT`. The declaration cannot have a default, `NOT NULL`, a primary or unique key, a check, a reference, a collation, a generated expression, or another suffix. Constrained existing-table additions require a schema-version bump or a companion table instead.
|
||||
|
||||
Matching numeric versions are necessary but not sufficient. A release can add a lazy or startup-repairable table, column, index, or trigger without advancing `user_version`, so two databases at the same version can still have different shapes. OpenClaw validates the canonical table definitions, constraints, indexes, triggers, virtual tables, and table options owned by the running release.
|
||||
|
||||
Installing OpenClaw manually through npm bypasses the updater guard. Database open checks still refuse an incompatible build.
|
||||
|
||||
## Preflight a target release
|
||||
|
||||
Before activating or rolling back a release, run that target release's CLI against one explicit copied state database:
|
||||
|
||||
```bash
|
||||
openclaw database preflight <copied-state.sqlite> --json
|
||||
```
|
||||
|
||||
The command does not read the default state directory or mutate the supplied file. It opens the supplied consolidated file as immutable/read-only, compares the target release's own schema contract, and reports one status:
|
||||
|
||||
- `exact`: the copied database matches the target release's runtime schema. Feature-local tables that are intentionally absent until first use do not require repair.
|
||||
- `startup-repairable`: the numeric version matches and a runtime-owned additive difference remains; startup needs a write to converge the shape.
|
||||
- `migration-required`: the database is older than the target release.
|
||||
- `incompatible`: the database is newer, or its same-version shape has blocking drift such as an unexpected column.
|
||||
- `indeterminate`: the file, integrity metadata, or ownership metadata could not be verified.
|
||||
|
||||
JSON output is identified by `schema: "openclaw.state-schema-preflight.v1"`.
|
||||
|
||||
Use a SQLite online backup or another WAL-aware snapshot produced while the source is safely coordinated. The resulting preflight input must be one consolidated file with no sibling `-wal`, `-shm`, or `-journal`; sidecars make the result `indeterminate`. Do not copy only the main `.sqlite` file from an active WAL database. Preflight the exact runtime that will be activated; a package version or numeric schema version alone does not prove same-version shape compatibility.
|
||||
|
||||
## Agent schema history
|
||||
|
||||
| Version | Change | First release |
|
||||
@@ -65,7 +87,7 @@ Version 3 was an unshipped development step folded into version 4.
|
||||
| Gateway background verifier | Run the full scan about once daily and log results |
|
||||
| Doctor, backup verification, and compaction | Run the full scan before accepting or rewriting the database |
|
||||
|
||||
The Gateway preflight reads schema headers only. The background verifier owns the slower full scan for databases that do not need migration.
|
||||
The Gateway startup preflight reads schema headers only. `openclaw database preflight` performs the release-local shape comparison for an explicit copied file. The background verifier owns the slower recurring full scan for live databases that do not need migration.
|
||||
Quarantine decisions live only in a dedicated `openclaw-quarantine.sqlite` store, so they survive damage to the databases being quarantined. Verification results are logged.
|
||||
|
||||
## Troubleshooting
|
||||
@@ -86,7 +108,7 @@ Since 2026.7.2, `openclaw update` refuses to install a release that cannot open
|
||||
|
||||
A newer OpenClaw build wrote your databases, and the running build is older. The error names the refusing install — release version, commit, and install root — plus the schema it supports and the schema it found.
|
||||
|
||||
Act on the install root, not the version. One release version string spans many `main` commits and several schema levels, so two installs can both call themselves `2026.7.2` and support different schemas. A prerelease version may not exist on the `latest` npm tag at all: check `npm view openclaw dist-tags` before reinstalling, because the tag carrying the schema you need may be `beta`, and reinstalling from `latest` can move you further away.
|
||||
Act on the install root, not the version. One release version string spans many `main` commits, schema levels, and same-version schema shapes, so two installs can both call themselves `2026.7.2` and still disagree about a database. A prerelease version may not exist on the `latest` npm tag at all: check `npm view openclaw dist-tags` before reinstalling, because the tag carrying the schema you need may be `beta`, and reinstalling from `latest` can move you further away.
|
||||
|
||||
A linked source checkout is the case where the commit misleads: `openclaw --version` reports the checkout's git HEAD, but the code actually executing is whatever `dist/` was last built. If the install root is a checkout, rebuild it (`pnpm build`) before concluding the version is wrong.
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ const rawSqliteAllowPathGroups = {
|
||||
"src/state/openclaw-state-db-schema-repair.ts",
|
||||
"src/state/openclaw-state-db-startup-checkpoint.ts",
|
||||
"src/state/openclaw-state-db.ts",
|
||||
"src/state/openclaw-state-ownership-operations.ts",
|
||||
"src/transcripts/sqlite-schema.ts",
|
||||
"src/state/sqlite-schema-shape.test-support.ts",
|
||||
],
|
||||
@@ -85,6 +86,7 @@ const rawSqliteAllowPathGroups = {
|
||||
"read-only schema preflight and integrity verification access": [
|
||||
"src/state/openclaw-database-preflight.ts",
|
||||
"src/state/openclaw-database-verify.worker.ts",
|
||||
"src/state/openclaw-state-ownership.ts",
|
||||
],
|
||||
"quarantine store must work when other databases are damaged": [
|
||||
"src/state/openclaw-quarantine-store.ts",
|
||||
|
||||
@@ -4,11 +4,11 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { CLAW_LAZY_ADDITIVE_STATE_COLUMNS } from "../state/openclaw-state-db-maintenance.js";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY } from "../state/openclaw-state-schema-compatibility.js";
|
||||
import { readClawResumeStateReadOnly } from "./package-resume.js";
|
||||
import { parseClawManifest } from "./schema.js";
|
||||
import type { ClawSourceIdentity } from "./types.js";
|
||||
@@ -39,7 +39,8 @@ function createBaseShapeState(params: {
|
||||
)`,
|
||||
)
|
||||
.run(params.packageRoot, join(params.packageRoot, "CLAW.md"), params.workspace);
|
||||
for (const column of CLAW_LAZY_ADDITIVE_STATE_COLUMNS) {
|
||||
for (const column of OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY.allowedMissingColumns ??
|
||||
[]) {
|
||||
const [table, name] = column.split(".");
|
||||
database.db.exec(`ALTER TABLE ${table} DROP COLUMN ${name};`);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,17 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
|
||||
// read, validate, migrate, or inherit proxy policy from operator state.
|
||||
policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" },
|
||||
},
|
||||
{
|
||||
commandPath: ["database"],
|
||||
// Release-local database inspection must not observe default state or load runtime policy.
|
||||
policy: {
|
||||
configGuard: "skip",
|
||||
loadPlugins: "never",
|
||||
hideBanner: true,
|
||||
ensureCliPath: false,
|
||||
networkProxy: "bypass",
|
||||
},
|
||||
},
|
||||
{
|
||||
commandPath: ["crestodian"], // hidden alias
|
||||
policy: { configGuard: "skip", loadPlugins: "never", ensureCliPath: false },
|
||||
|
||||
@@ -26,6 +26,7 @@ describe("command-startup-policy", () => {
|
||||
it("resolves config guard policy for Commander and invocation-aware commands", () => {
|
||||
for (const commandPath of [
|
||||
["backup", "create"],
|
||||
["database"],
|
||||
["config"],
|
||||
["config", "file"],
|
||||
["config", "validate"],
|
||||
|
||||
@@ -74,6 +74,11 @@ const coreEntrySpecs: readonly CommandGroupDescriptorSpec<
|
||||
loadModule: () => import("./register.backup.js"),
|
||||
exportName: "registerBackupCommand",
|
||||
},
|
||||
{
|
||||
commandNames: ["database"],
|
||||
loadModule: () => import("./register.database.js"),
|
||||
exportName: "registerDatabaseCommand",
|
||||
},
|
||||
{
|
||||
commandNames: ["migrate"],
|
||||
loadModule: () => import("./register.migrate.js"),
|
||||
|
||||
@@ -48,6 +48,12 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
|
||||
description: "Create and verify backup archives and SQLite snapshots",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
{
|
||||
name: "database",
|
||||
description: "Inspect shared-state schema compatibility and write ownership",
|
||||
hasSubcommands: true,
|
||||
parentDefaultHelp: true,
|
||||
},
|
||||
{
|
||||
name: "migrate",
|
||||
description: "Import state from another agent system",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Command } from "commander";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { defaultRuntime, writeRuntimeJson, writeRuntimeStdout } from "../../runtime.js";
|
||||
import {
|
||||
OPENCLAW_DATABASE_SCHEMA_DOCS_URL,
|
||||
preflightOpenClawStateDatabasePath,
|
||||
} from "../../state/openclaw-database-preflight.js";
|
||||
import { resolveDatabasePath } from "../../state/openclaw-state-db-maintenance.js";
|
||||
import { claimOpenClawStateOwnership } from "../../state/openclaw-state-ownership-operations.js";
|
||||
import { inspectOpenClawStateOwnershipAtPath } from "../../state/openclaw-state-ownership.js";
|
||||
import { applyParentDefaultHelpAction } from "./parent-default-help.js";
|
||||
|
||||
type DatabaseOutputOptions = { json?: boolean };
|
||||
|
||||
function writeDatabaseError(error: unknown, json: boolean): void {
|
||||
const message = formatErrorMessage(error);
|
||||
if (json) {
|
||||
writeRuntimeJson(defaultRuntime, { error: message });
|
||||
} else {
|
||||
defaultRuntime.error(message);
|
||||
}
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
async function runDatabasePreflight(databasePath: string, options: DatabaseOutputOptions) {
|
||||
const result = await preflightOpenClawStateDatabasePath(databasePath);
|
||||
if (options.json) {
|
||||
writeRuntimeJson(defaultRuntime, result);
|
||||
} else {
|
||||
const detail = result.reason ?? result.issues[0]?.message;
|
||||
writeRuntimeStdout(
|
||||
defaultRuntime,
|
||||
`Database preflight: ${result.status} (found ${result.foundVersion ?? "unknown"}, target ${result.targetVersion}).${detail ? `\n${detail}` : ""}\nSee ${OPENCLAW_DATABASE_SCHEMA_DOCS_URL}.\n`,
|
||||
);
|
||||
}
|
||||
if (result.status === "incompatible" || result.status === "indeterminate") {
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function runDatabaseOwnership(options: DatabaseOutputOptions & { manager?: string }): void {
|
||||
try {
|
||||
const databasePath = resolveDatabasePath({ env: process.env });
|
||||
const ownership =
|
||||
options.manager !== undefined
|
||||
? claimOpenClawStateOwnership(options.manager, {
|
||||
path: databasePath,
|
||||
env: process.env,
|
||||
})
|
||||
: inspectOpenClawStateOwnershipAtPath(databasePath);
|
||||
const status = ownership
|
||||
? { status: "external" as const, ownership }
|
||||
: { status: "unowned" as const };
|
||||
if (options.json) {
|
||||
writeRuntimeJson(defaultRuntime, { databasePath, ...status });
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
status.status === "external"
|
||||
? `Shared state is externally owned by ${status.ownership.managerId}.`
|
||||
: "Shared state is not externally owned.";
|
||||
writeRuntimeStdout(defaultRuntime, `${message}\nSee ${OPENCLAW_DATABASE_SCHEMA_DOCS_URL}.\n`);
|
||||
} catch (error) {
|
||||
writeDatabaseError(error, options.json === true);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerDatabaseCommand(program: Command): void {
|
||||
const database = program
|
||||
.command("database")
|
||||
.description("Inspect shared-state schema compatibility and write ownership")
|
||||
.addHelpText("after", `\nDocs: ${OPENCLAW_DATABASE_SCHEMA_DOCS_URL}\n`);
|
||||
|
||||
database
|
||||
.command("preflight")
|
||||
.description("Compare one copied SQLite file with this release's state schema")
|
||||
.argument("<path>", "explicit copied SQLite database path")
|
||||
.option("--json", "emit machine-readable JSON", false)
|
||||
.action(async (databasePath: string, options: DatabaseOutputOptions) => {
|
||||
await runDatabasePreflight(databasePath, options);
|
||||
});
|
||||
|
||||
const ownership = database.command("ownership").description("Inspect or claim write ownership");
|
||||
ownership
|
||||
.command("status")
|
||||
.description("Show durable shared-state write ownership")
|
||||
.option("--json", "emit machine-readable JSON", false)
|
||||
.action((options: DatabaseOutputOptions) => runDatabaseOwnership(options));
|
||||
ownership
|
||||
.command("claim")
|
||||
.description("Claim shared-state writes for the active external supervisor")
|
||||
.requiredOption("--manager <id>", "stable external manager identifier")
|
||||
.option("--json", "emit machine-readable JSON", false)
|
||||
.action((options: DatabaseOutputOptions & { manager: string }) =>
|
||||
runDatabaseOwnership(options),
|
||||
);
|
||||
applyParentDefaultHelpAction(ownership);
|
||||
applyParentDefaultHelpAction(database);
|
||||
}
|
||||
@@ -28,6 +28,8 @@ const JSON_NOT_APPLICABLE = {
|
||||
commands: [
|
||||
"backup",
|
||||
"backup sqlite",
|
||||
"database",
|
||||
"database ownership",
|
||||
"message",
|
||||
"message thread",
|
||||
"message emoji",
|
||||
|
||||
@@ -164,6 +164,7 @@ const createCliProgressMock = vi.hoisted(() =>
|
||||
})),
|
||||
);
|
||||
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({})));
|
||||
const readSourceConfigBestEffortMock = vi.hoisted(() => vi.fn(async () => ({})));
|
||||
const startProxyMock = vi.hoisted(() =>
|
||||
vi.fn<(config: unknown) => Promise<unknown>>(async () => null),
|
||||
);
|
||||
@@ -428,6 +429,7 @@ vi.mock("./progress.js", () => ({
|
||||
|
||||
vi.mock("../config/io.js", () => ({
|
||||
readBestEffortConfig: loadConfigMock,
|
||||
readSourceConfigBestEffort: readSourceConfigBestEffortMock,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/net/proxy/proxy-lifecycle.js", () => ({
|
||||
@@ -2367,27 +2369,24 @@ describe("runCli exit behavior", () => {
|
||||
it.each([
|
||||
["root command", ["node", "openclaw", "update", "--dry-run", "--json"]],
|
||||
["root shorthand", ["node", "openclaw", "--update", "--dry-run", "--json"]],
|
||||
])("reads proxy config without observation for the update dry-run %s", async (_name, argv) => {
|
||||
])("reads source-only proxy config for the update dry-run %s", async (_name, argv) => {
|
||||
tryRouteCliMock.mockResolvedValueOnce(true);
|
||||
loadConfigMock.mockReturnValueOnce({ proxy: { selected: "dry-run" } });
|
||||
readSourceConfigBestEffortMock.mockResolvedValueOnce({ proxy: { selected: "dry-run" } });
|
||||
|
||||
await runCli(argv);
|
||||
|
||||
expect(loadConfigMock).toHaveBeenCalledWith({
|
||||
observe: false,
|
||||
skipPluginValidation: true,
|
||||
});
|
||||
expect(readSourceConfigBestEffortMock).toHaveBeenCalledOnce();
|
||||
expect(loadConfigMock).not.toHaveBeenCalled();
|
||||
expect(startProxyMock).toHaveBeenCalledWith({ selected: "dry-run" });
|
||||
});
|
||||
|
||||
it("keeps observed proxy config reads for mutable updates", async () => {
|
||||
it("reads source-only proxy config for mutable updates", async () => {
|
||||
tryRouteCliMock.mockResolvedValueOnce(true);
|
||||
|
||||
await runCli(["node", "openclaw", "update"]);
|
||||
|
||||
expect(loadConfigMock).toHaveBeenCalledWith({
|
||||
skipPluginValidation: true,
|
||||
});
|
||||
expect(readSourceConfigBestEffortMock).toHaveBeenCalledOnce();
|
||||
expect(loadConfigMock).not.toHaveBeenCalled();
|
||||
expect(startProxyMock).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
@@ -2565,6 +2564,23 @@ describe("runCli exit behavior", () => {
|
||||
expect(loadDotEnvMock).toHaveBeenCalledWith({ loadGlobalEnv: true, quiet: true });
|
||||
});
|
||||
|
||||
it("keeps explicit database preflight isolated from default state selection", async () => {
|
||||
tryRouteCliMock.mockResolvedValueOnce(true);
|
||||
|
||||
await runCli([
|
||||
"node",
|
||||
"openclaw",
|
||||
"database",
|
||||
"preflight",
|
||||
"/tmp/openclaw-candidate.sqlite",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(loadDotEnvMock).not.toHaveBeenCalled();
|
||||
expect(loadConfigMock).not.toHaveBeenCalled();
|
||||
expect(startProxyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps agent exec outside the CLI dotenv loader", async () => {
|
||||
buildProgramMock.mockReturnValueOnce({ commands: [], parseAsync: vi.fn() });
|
||||
await runCli(["node", "openclaw", "agent", "exec", "test prompt"]);
|
||||
|
||||
+12
-7
@@ -1155,6 +1155,7 @@ async function runCliWithPreparedOutputMode(
|
||||
const normalizedInvocation = resolveCliArgvInvocation(normalizedArgv);
|
||||
const isHelpOrVersionInvocation = normalizedInvocation.hasHelpOrVersion;
|
||||
const isGatewayRunInvocation = isGatewayRunInvocationArgv(normalizedArgv);
|
||||
const isDatabaseInvocation = normalizedInvocation.commandPath[0] === "database";
|
||||
// Gateway pre-bootstrap owns state/config dotenv selection. This phase only
|
||||
// needs the workspace file, so avoid importing the loader when it is absent.
|
||||
const loadGlobalEnv = !isGatewayRunInvocation;
|
||||
@@ -1165,6 +1166,7 @@ async function runCliWithPreparedOutputMode(
|
||||
|
||||
if (
|
||||
!isHelpOrVersionInvocation &&
|
||||
!isDatabaseInvocation &&
|
||||
!isAgentExecInvocation(normalizedInvocation.commandPath) &&
|
||||
shouldLoadCliDotEnv(loadGlobalEnv)
|
||||
) {
|
||||
@@ -1212,12 +1214,14 @@ async function runCliWithPreparedOutputMode(
|
||||
}).skipConfigGuard;
|
||||
const readBestEffortCliConfig = async (): Promise<OpenClawConfig> => {
|
||||
if (!bestEffortConfigPromise) {
|
||||
bestEffortConfigPromise = import("../config/io.js").then(({ readBestEffortConfig }) =>
|
||||
readBestEffortConfig({
|
||||
...(isolateProxyConfigEnv ? { isolateEnv: true, observe: false } : {}),
|
||||
...(skipBestEffortConfigObservation ? { observe: false } : {}),
|
||||
skipPluginValidation: true,
|
||||
}),
|
||||
bestEffortConfigPromise = import("../config/io.js").then((configIo) =>
|
||||
normalizedInvocation.primary === "update"
|
||||
? configIo.readSourceConfigBestEffort()
|
||||
: configIo.readBestEffortConfig({
|
||||
...(isolateProxyConfigEnv ? { isolateEnv: true, observe: false } : {}),
|
||||
...(skipBestEffortConfigObservation ? { observe: false } : {}),
|
||||
skipPluginValidation: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return await bestEffortConfigPromise;
|
||||
@@ -1226,6 +1230,7 @@ async function runCliWithPreparedOutputMode(
|
||||
(trace): trace is ReturnType<typeof createGatewayStartupTrace> => Boolean(trace),
|
||||
);
|
||||
if (
|
||||
!isDatabaseInvocation &&
|
||||
(await Promise.all(startupTraces.map((trace) => trace.requiresDiagnosticsConfig()))).some(
|
||||
Boolean,
|
||||
)
|
||||
@@ -1452,7 +1457,7 @@ async function runCliWithPreparedOutputMode(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isHelpOrVersionInvocation) {
|
||||
if (!isHelpOrVersionInvocation && !isDatabaseInvocation) {
|
||||
await bootstrapCliProxyCaptureAndDispatcher(startupTrace, {
|
||||
ensureDispatcher: shouldUseCliEnvProxy,
|
||||
});
|
||||
|
||||
@@ -5,6 +5,11 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { claimOpenClawStateOwnership } from "../state/openclaw-state-ownership-operations.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
@@ -67,6 +72,7 @@ function runUpdateDryRun(root: string, args: string[]) {
|
||||
OPENCLAW_HOME: root,
|
||||
OPENCLAW_NO_RESPAWN: "1",
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_SUPERVISOR_MODE: undefined,
|
||||
VITEST: undefined,
|
||||
VITEST_POOL_ID: undefined,
|
||||
VITEST_WORKER_ID: undefined,
|
||||
@@ -154,4 +160,41 @@ describe("update dry-run process state", () => {
|
||||
}).toEqual(markerHashesBefore);
|
||||
expect(snapshotDatabaseArtifacts(await snapshotTree(root))).toEqual(databaseArtifactsBefore);
|
||||
});
|
||||
|
||||
it("fences the full mutable update path before observation or action", async () => {
|
||||
const root = tempDirs.make("openclaw-update-owned-state-");
|
||||
const configPath = path.join(root, "config", "openclaw.json");
|
||||
const stateDir = path.join(root, "state");
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(configPath, '{ "gateway": { "mode": "local" } }\n');
|
||||
const externalEnv = {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
OPENCLAW_CONFIG_PATH: configPath,
|
||||
OPENCLAW_HOME: root,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_SUPERVISOR_MODE: "external",
|
||||
};
|
||||
claimOpenClawStateOwnership("gateway-supervisor", { env: externalEnv });
|
||||
const databasePath = openOpenClawStateDatabase({ env: externalEnv }).path;
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const before = await snapshotTree(root);
|
||||
const beforeDatabaseHash = await sha256File(databasePath);
|
||||
|
||||
const refused = runUpdateDryRun(root, [
|
||||
"update",
|
||||
"--timeout",
|
||||
"invalid",
|
||||
"--no-restart",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(refused.error).toBeUndefined();
|
||||
expect(refused.status).not.toBe(0);
|
||||
expect(`${refused.stdout}\n${refused.stderr}`).toMatch(/gateway-supervisor/u);
|
||||
expect(`${refused.stdout}\n${refused.stderr}`).toMatch(/OPENCLAW_SUPERVISOR_MODE=external/u);
|
||||
expect(`${refused.stdout}\n${refused.stderr}`).not.toMatch(/invalid timeout/iu);
|
||||
expect(await snapshotTree(root)).toEqual(before);
|
||||
expect(await sha256File(databasePath)).toBe(beforeDatabaseHash);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,8 @@ import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot
|
||||
import { setActiveDegradedPlugins } from "../plugins/runtime-degraded-state.js";
|
||||
import { ExitError } from "../runtime.js";
|
||||
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import { assertOpenClawStateWriteAllowed } from "../state/openclaw-state-ownership.js";
|
||||
import { resolveHomeDir } from "../utils.js";
|
||||
import { noteIncludeConfinementWarning } from "./doctor-config-analysis.js";
|
||||
import {
|
||||
@@ -205,6 +207,12 @@ export async function runDoctorConfigPreflight(
|
||||
} = {},
|
||||
): Promise<DoctorConfigPreflightResult> {
|
||||
const stateMigrationsRequested = options.migrateState !== false;
|
||||
if (stateMigrationsRequested) {
|
||||
assertOpenClawStateWriteAllowed({
|
||||
databasePath: resolveOpenClawStateSqlitePath(process.env),
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
const measurePreflightStep = <T>(name: string, run: () => T | Promise<T>) =>
|
||||
measureDoctorConfigPreflightStep(name, run, options.measure);
|
||||
const gatewayStartupCheckpointRequired = options.requireStartupMigrationCheckpoint === true;
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
clearOpenClawStateDatabaseOpenFailure,
|
||||
ensureOpenClawStatePermissions,
|
||||
isOpenClawStateDatabaseOpen,
|
||||
STATE_READ_ONLY_COMPATIBLE_MISSING_COLUMNS,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import { assertOpenClawStateWriteAllowed } from "../state/openclaw-state-ownership.js";
|
||||
import {
|
||||
compactDoctorSqliteFile,
|
||||
type DoctorSqliteCompactSnapshot,
|
||||
@@ -61,6 +61,7 @@ export async function runDoctorStateSqliteCompact(
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`Canonical OpenClaw state database is not a regular file: ${sqlitePath}`);
|
||||
}
|
||||
assertOpenClawStateWriteAllowed({ databasePath: sqlitePath, env });
|
||||
const withMaintenanceLock = deps.withMaintenanceLock ?? withDoctorSqliteMaintenanceLock;
|
||||
return await withMaintenanceLock({
|
||||
env,
|
||||
@@ -85,11 +86,10 @@ export async function runDoctorStateSqliteCompact(
|
||||
},
|
||||
...(deps.busyTimeoutMs !== undefined ? { busyTimeoutMs: deps.busyTimeoutMs } : {}),
|
||||
sqlitePath,
|
||||
validateBeforeMutation: (database) =>
|
||||
assertOpenClawStateDatabaseForMaintenance(database, {
|
||||
pathname: sqlitePath,
|
||||
allowedMissingColumns: STATE_READ_ONLY_COMPATIBLE_MISSING_COLUMNS,
|
||||
}),
|
||||
validateBeforeMutation: (database) => {
|
||||
assertOpenClawStateWriteAllowed({ database, databasePath: sqlitePath, env });
|
||||
assertOpenClawStateDatabaseForMaintenance(database, { pathname: sqlitePath });
|
||||
},
|
||||
});
|
||||
return {
|
||||
...compact,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { OpenClawStateOwnershipError } from "../state/openclaw-state-ownership.js";
|
||||
|
||||
export type ConfigHealthFingerprint = {
|
||||
hash: string;
|
||||
@@ -94,7 +95,10 @@ export function readConfigHealthStateFromStore(deps: ConfigHealthStateDeps): Con
|
||||
]),
|
||||
),
|
||||
};
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof OpenClawStateOwnershipError) {
|
||||
throw error;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -139,6 +143,9 @@ export function writeConfigHealthStateToStore(
|
||||
{ env: resolveConfigHealthStateEnv(deps) },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof OpenClawStateOwnershipError) {
|
||||
throw error;
|
||||
}
|
||||
deps.logger.warn(`Config health-state write failed: ${formatErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ import { getTotalQueueSize } from "../process/command-queue.js";
|
||||
import { getActiveGatewayRootWorkCount } from "../process/gateway-work-admission.js";
|
||||
import { createLazyPromise } from "../shared/lazy-runtime.js";
|
||||
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import { assertOpenClawStateWriteAllowed } from "../state/openclaw-state-ownership.js";
|
||||
import { ADMIN_SCOPE } from "./method-scopes.js";
|
||||
import { listCoreGatewayMethodNames } from "./methods/core-descriptors.js";
|
||||
import {
|
||||
@@ -78,6 +80,10 @@ export async function prepareGatewayServerBootstrap(input: {
|
||||
const { port, opts, log, logSecrets, loadWorkerEnvironmentStartupModule } = input;
|
||||
const formatRuntimeGatewayAuthTokenWarning = input.formatRuntimeGatewayAuthTokenWarning;
|
||||
normalizeStateDirEnv(process.env);
|
||||
assertOpenClawStateWriteAllowed({
|
||||
databasePath: resolveOpenClawStateSqlitePath(process.env),
|
||||
env: process.env,
|
||||
});
|
||||
const [
|
||||
{
|
||||
OPENCLAW_DATABASE_SCHEMA_DOCS_URL,
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
import path from "node:path";
|
||||
import { DatabaseSync, type StatementSync } from "node:sqlite";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { openNodeSqliteDatabase, resolveNodeSqliteLocation } from "./node-sqlite.js";
|
||||
import {
|
||||
openNodeSqliteDatabase,
|
||||
resolveImmutableSqliteFileUri,
|
||||
resolveNodeSqliteLocation,
|
||||
} from "./node-sqlite.js";
|
||||
|
||||
const originalPrepare = Reflect.get(DatabaseSync.prototype, "prepare") as DatabaseSync["prepare"];
|
||||
|
||||
@@ -126,6 +130,15 @@ describe("node SQLite locations", () => {
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(resolvedPaths.size);
|
||||
expect(namespacedSpy).toHaveBeenCalledTimes(resolvedPaths.size);
|
||||
});
|
||||
|
||||
it("preserves the Windows long-path namespace in immutable SQLite URIs", () => {
|
||||
const pathname = String.raw`C:\deep state\openclaw.sqlite`;
|
||||
const namespacedPath = String.raw`\\?\C:\deep state\openclaw.sqlite`;
|
||||
|
||||
expect(resolveImmutableSqliteFileUri(pathname, "win32")).toBe(
|
||||
`file:${encodeURIComponent(namespacedPath)}?mode=ro&immutable=1`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("node SQLite safety", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Loads node:sqlite with OpenClaw warning handling.
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { formatErrorMessage } from "./errors.js";
|
||||
import { isSqliteWalResetSafeVersion } from "./sqlite-runtime-version.js";
|
||||
import { isSqliteLockError } from "./sqlite-transaction.js";
|
||||
@@ -29,6 +30,20 @@ export function resolveNodeSqliteLocation(location: string): string {
|
||||
return resolveSqliteFilesystemPath(location);
|
||||
}
|
||||
|
||||
/** Build an immutable SQLite URI without losing the Windows long-path namespace. */
|
||||
export function resolveImmutableSqliteFileUri(
|
||||
pathname: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string {
|
||||
if (platform === "win32") {
|
||||
const namespacedPath = path.win32.toNamespacedPath(path.win32.resolve(pathname));
|
||||
// SQLite decodes path escapes after separating the query string, so the
|
||||
// encoded \\?\ prefix reaches the Windows VFS without becoming URI syntax.
|
||||
return `file:${encodeURIComponent(namespacedPath)}?mode=ro&immutable=1`;
|
||||
}
|
||||
return `${pathToFileURL(path.resolve(pathname)).href}?mode=ro&immutable=1`;
|
||||
}
|
||||
|
||||
function assertSqliteWalResetSafeVersion(version: string, nodeVersion: string): void {
|
||||
if (isSqliteWalResetSafeVersion(version)) {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assertSqliteSchemaContains } from "./sqlite-schema-contract.js";
|
||||
import { assertSqliteSchemaContains, collectSqliteSchemaIssues } from "./sqlite-schema-contract.js";
|
||||
|
||||
const CANONICAL_SCHEMA = `
|
||||
CREATE TABLE parents (
|
||||
@@ -248,6 +248,39 @@ describe("assertSqliteSchemaContains", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a stable missing-table issue", () => {
|
||||
const database = createDatabase("CREATE TABLE unrelated (id INTEGER PRIMARY KEY);");
|
||||
try {
|
||||
expect(collectSqliteSchemaIssues(database, CANONICAL_SCHEMA)).toContainEqual({
|
||||
code: "missing-table",
|
||||
objectName: "parents",
|
||||
message: "missing table parents",
|
||||
});
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a stable virtual-definition issue", () => {
|
||||
const database = createDatabase(
|
||||
"CREATE VIRTUAL TABLE search_records USING fts5(body, tokenize='porter');",
|
||||
);
|
||||
try {
|
||||
expect(
|
||||
collectSqliteSchemaIssues(
|
||||
database,
|
||||
"CREATE VIRTUAL TABLE search_records USING fts5(body);",
|
||||
),
|
||||
).toContainEqual({
|
||||
code: "virtual-table-definition-drift",
|
||||
objectName: "search_records",
|
||||
message: "virtual table definition differs for search_records",
|
||||
});
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "table",
|
||||
@@ -331,6 +364,79 @@ describe("assertSqliteSchemaContains", () => {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "type",
|
||||
schema: CANONICAL_SCHEMA.replace("value TEXT NOT NULL", "value BLOB NOT NULL"),
|
||||
issue: { code: "column-definition-drift", objectName: "parents.value" },
|
||||
},
|
||||
{
|
||||
name: "default",
|
||||
schema: CANONICAL_SCHEMA.replace(" DEFAULT 'pending'", " DEFAULT 'other'"),
|
||||
issue: { code: "column-definition-drift", objectName: "events.payload" },
|
||||
},
|
||||
{
|
||||
name: "nullability",
|
||||
schema: CANONICAL_SCHEMA.replace("value TEXT NOT NULL", "value TEXT"),
|
||||
issue: { code: "column-definition-drift", objectName: "parents.value" },
|
||||
},
|
||||
{
|
||||
name: "inline primary key",
|
||||
schema: CANONICAL_SCHEMA.replace("id INTEGER PRIMARY KEY,", "id INTEGER,"),
|
||||
issue: { code: "column-definition-drift", objectName: "features.id" },
|
||||
},
|
||||
{
|
||||
name: "table constraint",
|
||||
schema: CANONICAL_SCHEMA.replace(
|
||||
/,\s*FOREIGN KEY \(parent_id\) REFERENCES parents\(id\) ON DELETE CASCADE/u,
|
||||
"",
|
||||
),
|
||||
issue: { code: "table-constraint-drift", objectName: "children" },
|
||||
},
|
||||
{
|
||||
name: "index",
|
||||
schema: CANONICAL_SCHEMA.replace(
|
||||
"CREATE INDEX idx_children_parent ON children(parent_id, id)",
|
||||
"CREATE INDEX idx_children_parent ON children(id, parent_id)",
|
||||
),
|
||||
issue: { code: "missing-or-drifted-index", objectName: "idx_children_parent" },
|
||||
},
|
||||
{
|
||||
name: "trigger",
|
||||
schema: CANONICAL_SCHEMA.replace(
|
||||
"UPDATE parents SET value = NEW.value WHERE id = NEW.parent_id",
|
||||
"UPDATE parents SET value = NULL WHERE id = NEW.parent_id",
|
||||
),
|
||||
issue: {
|
||||
code: "missing-or-drifted-trigger",
|
||||
objectName: "children_value_after_update",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "table options",
|
||||
schema: CANONICAL_SCHEMA.replace(
|
||||
` CREATE TABLE parents (
|
||||
id TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL CHECK (length(value) > 0)
|
||||
);`,
|
||||
` CREATE TABLE parents (
|
||||
id TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL CHECK (length(value) > 0)
|
||||
) STRICT;`,
|
||||
),
|
||||
issue: { code: "table-options-drift", objectName: "parents" },
|
||||
},
|
||||
])("returns a stable issue for drifted $name", ({ schema, issue }) => {
|
||||
const database = createDatabase(schema);
|
||||
try {
|
||||
expect(collectSqliteSchemaIssues(database, CANONICAL_SCHEMA)).toContainEqual(
|
||||
expect.objectContaining(issue),
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function createDatabase(schema: string): DatabaseSync {
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { openNodeSqliteDatabase } from "./node-sqlite.js";
|
||||
import {
|
||||
createSqliteSchemaIssue,
|
||||
legacySqliteSchemaIssueMessages,
|
||||
throwSqliteSchemaMismatches,
|
||||
type SqliteSchemaCompatibility,
|
||||
type SqliteSchemaIssue,
|
||||
type SqliteSchemaIssueCode,
|
||||
} from "./sqlite-schema-issues.js";
|
||||
import {
|
||||
findSqlCharacter,
|
||||
findSqlClosingParenthesis,
|
||||
normalizeSchemaSql,
|
||||
normalizeSqlIdentifier,
|
||||
normalizeSqlWhitespace,
|
||||
quoteSqliteIdentifier,
|
||||
readSqlToken,
|
||||
readTableConstraintKeyword,
|
||||
splitSqlList,
|
||||
} from "./sqlite-schema-sql.js";
|
||||
|
||||
export type { SqliteSchemaCompatibility, SqliteSchemaIssue } from "./sqlite-schema-issues.js";
|
||||
|
||||
type SqliteIndexListRow = {
|
||||
name: string;
|
||||
@@ -66,43 +87,7 @@ export type CanonicalSqliteNamedIndexContract = {
|
||||
unique: boolean;
|
||||
};
|
||||
|
||||
export type SqliteSchemaCompatibility = {
|
||||
/**
|
||||
* Canonical additive tables that may be absent until their owning feature
|
||||
* performs its one-time lazy ensure. Present tables still require the exact
|
||||
* canonical shape.
|
||||
*/
|
||||
allowedMissingTables?: readonly string[];
|
||||
/** Additive columns that may be absent until their owning feature lazily ensures them. */
|
||||
allowedMissingColumns?: readonly string[];
|
||||
/**
|
||||
* Exact definitions produced by supported additive migrations when SQLite
|
||||
* requires a temporary default that the clean schema does not retain.
|
||||
*/
|
||||
allowedColumnDefinitions?: Readonly<Record<string, readonly string[]>>;
|
||||
/**
|
||||
* Allow unexpected columns declared as a name plus one bare nullable SQLite
|
||||
* STRICT datatype. Allowed-missing tables remain exact when present.
|
||||
*/
|
||||
allowCompatibleAdditiveColumns?: boolean;
|
||||
/**
|
||||
* Exact owner-defined trigger groups that may be absent when their derived
|
||||
* or lazily ensured schema is absent, but must be complete and canonical
|
||||
* when present.
|
||||
*/
|
||||
optionalCanonicalTriggerGroups?: readonly {
|
||||
/** The trigger group is optional only while this canonical table is absent. */
|
||||
optionalWhenTableMissing?: string;
|
||||
tableName: string;
|
||||
triggers: readonly {
|
||||
name: string;
|
||||
sql: string;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
const schemaContractCache = new Map<string, SqliteSchemaContract>();
|
||||
const TABLE_CONSTRAINT_KEYWORDS = new Set(["CHECK", "FOREIGN", "PRIMARY", "UNIQUE"]);
|
||||
|
||||
/**
|
||||
* Require every object from one committed schema while allowing unrelated
|
||||
@@ -114,33 +99,52 @@ export function assertSqliteSchemaContains(
|
||||
schemaSql: string,
|
||||
compatibility: SqliteSchemaCompatibility = {},
|
||||
): void {
|
||||
const issues = collectSqliteSchemaIssues(database, schemaSql, compatibility);
|
||||
if (issues.length > 0) {
|
||||
throwSqliteSchemaMismatches(databaseLabel, legacySqliteSchemaIssueMessages(issues));
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect stable, machine-readable differences from one committed schema. */
|
||||
export function collectSqliteSchemaIssues(
|
||||
database: DatabaseSync,
|
||||
schemaSql: string,
|
||||
compatibility: SqliteSchemaCompatibility = {},
|
||||
): SqliteSchemaIssue[] {
|
||||
const expected = getSqliteSchemaContract(schemaSql);
|
||||
const allowedMissingTables = new Set(compatibility.allowedMissingTables ?? []);
|
||||
|
||||
const mismatches: string[] = [];
|
||||
const issues: SqliteSchemaIssue[] = [];
|
||||
const add = (code: SqliteSchemaIssueCode, objectName: string, message?: string) => {
|
||||
issues.push(createSqliteSchemaIssue(code, objectName, message));
|
||||
};
|
||||
for (const [tableName, expectedTable] of expected) {
|
||||
const actualTable = collectSqliteTableContract(database, tableName);
|
||||
if (!actualTable) {
|
||||
if (allowedMissingTables.has(tableName)) {
|
||||
continue;
|
||||
}
|
||||
mismatches.push(`missing table ${tableName}`);
|
||||
add("missing-table", tableName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const definitionMismatch = compareTableDefinitions(
|
||||
tableName,
|
||||
actualTable.definition,
|
||||
expectedTable.definition,
|
||||
compatibility,
|
||||
!allowedMissingTables.has(tableName),
|
||||
issues.push(
|
||||
...compareTableDefinitions(
|
||||
tableName,
|
||||
actualTable.definition,
|
||||
expectedTable.definition,
|
||||
compatibility,
|
||||
!allowedMissingTables.has(tableName),
|
||||
),
|
||||
);
|
||||
if (definitionMismatch) {
|
||||
mismatches.push(`${definitionMismatch} differ for ${tableName}`);
|
||||
}
|
||||
for (const expectedIndex of expectedTable.indexes) {
|
||||
if (!actualTable.indexes.some((actualIndex) => isEqual(actualIndex, expectedIndex))) {
|
||||
mismatches.push(`missing or drifted index ${expectedIndex.name ?? `on ${tableName}`}`);
|
||||
const objectName = expectedIndex.name ?? tableName;
|
||||
add(
|
||||
"missing-or-drifted-index",
|
||||
objectName,
|
||||
`missing or drifted index ${expectedIndex.name ?? `on ${tableName}`}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const actualIndex of actualTable.indexes) {
|
||||
@@ -148,7 +152,12 @@ export function assertSqliteSchemaContains(
|
||||
actualIndex.unique === 1 &&
|
||||
!expectedTable.indexes.some((expectedIndex) => isEqual(actualIndex, expectedIndex))
|
||||
) {
|
||||
mismatches.push(`unexpected unique index ${actualIndex.name ?? `on ${tableName}`}`);
|
||||
const objectName = actualIndex.name ?? tableName;
|
||||
add(
|
||||
"unexpected-unique-index",
|
||||
objectName,
|
||||
`unexpected unique index ${actualIndex.name ?? `on ${tableName}`}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const optionalCanonicalTriggerGroups = collectOptionalCanonicalTriggerGroups(
|
||||
@@ -171,7 +180,7 @@ export function assertSqliteSchemaContains(
|
||||
continue;
|
||||
}
|
||||
if (!actualTable.triggers.some((actualTrigger) => isEqual(actualTrigger, expectedTrigger))) {
|
||||
mismatches.push(`missing or drifted trigger ${expectedTrigger.name}`);
|
||||
add("missing-or-drifted-trigger", expectedTrigger.name);
|
||||
}
|
||||
}
|
||||
for (const triggerGroup of optionalCanonicalTriggerGroups) {
|
||||
@@ -187,7 +196,7 @@ export function assertSqliteSchemaContains(
|
||||
if (
|
||||
!actualTable.triggers.some((actualTrigger) => isEqual(actualTrigger, canonicalTrigger))
|
||||
) {
|
||||
mismatches.push(`missing or drifted trigger ${canonicalTrigger.name}`);
|
||||
add("missing-or-drifted-trigger", canonicalTrigger.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,23 +209,20 @@ export function assertSqliteSchemaContains(
|
||||
isEqual(actualTrigger, canonicalTrigger),
|
||||
)
|
||||
) {
|
||||
mismatches.push(`unexpected trigger ${actualTrigger.name}`);
|
||||
add("unexpected-trigger", actualTrigger.name);
|
||||
}
|
||||
}
|
||||
if (actualTable.virtualTableSql !== expectedTable.virtualTableSql) {
|
||||
mismatches.push(`virtual table definition differs for ${tableName}`);
|
||||
add("virtual-table-definition-drift", tableName);
|
||||
}
|
||||
if (
|
||||
actualTable.strict !== expectedTable.strict ||
|
||||
actualTable.withoutRowid !== expectedTable.withoutRowid
|
||||
) {
|
||||
mismatches.push(`table options differ for ${tableName}`);
|
||||
add("table-options-drift", tableName);
|
||||
}
|
||||
}
|
||||
|
||||
if (mismatches.length > 0) {
|
||||
throwSqliteSchemaMismatches(databaseLabel, mismatches);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** Require stable canonical tables before a version-specific additive migration. */
|
||||
@@ -241,16 +247,6 @@ export function assertSqliteSchemaTablesPresent(
|
||||
}
|
||||
}
|
||||
|
||||
function throwSqliteSchemaMismatches(databaseLabel: string, mismatches: string[]): never {
|
||||
const shown = mismatches.slice(0, 8);
|
||||
if (mismatches.length > shown.length) {
|
||||
shown.push(`${mismatches.length - shown.length} additional mismatch(es)`);
|
||||
}
|
||||
throw new Error(
|
||||
`SQLite schema is incomplete or noncanonical for ${databaseLabel}: ${shown.join("; ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Return every explicit named index owned by one committed schema. */
|
||||
export function getCanonicalSqliteNamedIndexContracts(
|
||||
schemaSql: string,
|
||||
@@ -443,38 +439,52 @@ function compareTableDefinitions(
|
||||
expected: SqliteTableDefinition | null,
|
||||
compatibility: SqliteSchemaCompatibility,
|
||||
allowCompatibleAdditiveColumns: boolean,
|
||||
): "column definitions" | "table constraints" | "table definition" | null {
|
||||
): SqliteSchemaIssue[] {
|
||||
const issues: SqliteSchemaIssue[] = [];
|
||||
const add = (code: SqliteSchemaIssueCode, objectName: string) => {
|
||||
issues.push(createSqliteSchemaIssue(code, objectName));
|
||||
};
|
||||
if (!actual || !expected) {
|
||||
return actual === expected ? null : "table definition";
|
||||
if (actual !== expected) {
|
||||
add("table-definition-drift", tableName);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
const allowedMissingColumns = new Set(compatibility.allowedMissingColumns ?? []);
|
||||
const unexpectedColumns = [...actual.columns].filter(
|
||||
([columnName]) => !expected.columns.has(columnName),
|
||||
);
|
||||
if (
|
||||
unexpectedColumns.some(
|
||||
([, definition]) =>
|
||||
!allowCompatibleAdditiveColumns ||
|
||||
!compatibility.allowCompatibleAdditiveColumns ||
|
||||
!isCompatibleAdditiveColumnDefinition(definition),
|
||||
)
|
||||
) {
|
||||
return "column definitions";
|
||||
for (const [columnName, definition] of actual.columns) {
|
||||
if (!expected.columns.has(columnName)) {
|
||||
if (
|
||||
allowCompatibleAdditiveColumns &&
|
||||
compatibility.allowCompatibleAdditiveColumns &&
|
||||
isCompatibleAdditiveColumnDefinition(definition)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const objectName = `${tableName}.${columnName}`;
|
||||
add("unexpected-column", objectName);
|
||||
}
|
||||
}
|
||||
for (const [columnName, expectedDefinition] of expected.columns) {
|
||||
const objectName = `${tableName}.${columnName}`;
|
||||
const actualDefinition = actual.columns.get(columnName);
|
||||
if (actualDefinition === undefined && allowedMissingColumns.has(`${tableName}.${columnName}`)) {
|
||||
if (actualDefinition === undefined) {
|
||||
if (!allowedMissingColumns.has(objectName)) {
|
||||
add("missing-column", objectName);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (actualDefinition === expectedDefinition) {
|
||||
continue;
|
||||
}
|
||||
const allowed = compatibility.allowedColumnDefinitions?.[`${tableName}.${columnName}`] ?? [];
|
||||
const allowed = compatibility.allowedColumnDefinitions?.[objectName] ?? [];
|
||||
if (!allowed.some((definition) => normalizeSqlWhitespace(definition) === actualDefinition)) {
|
||||
return "column definitions";
|
||||
add("column-definition-drift", objectName);
|
||||
}
|
||||
}
|
||||
return isEqual(actual.constraints, expected.constraints) ? null : "table constraints";
|
||||
if (!isEqual(actual.constraints, expected.constraints)) {
|
||||
add("table-constraint-drift", tableName);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
const SQLITE_STRICT_DATATYPES = new Set(["ANY", "BLOB", "INT", "INTEGER", "REAL", "TEXT"]);
|
||||
@@ -525,59 +535,6 @@ function parseTableDefinition(sql: string | null, tableName: string): SqliteTabl
|
||||
};
|
||||
}
|
||||
|
||||
type SqlToken = {
|
||||
end: number;
|
||||
keyword: string | null;
|
||||
raw: string;
|
||||
};
|
||||
|
||||
function readTableConstraintKeyword(sql: string, first: SqlToken): string | null {
|
||||
let token: SqlToken | null = first;
|
||||
if (token.keyword === "CONSTRAINT") {
|
||||
const name = readSqlToken(sql, token.end);
|
||||
token = name ? readSqlToken(sql, name.end) : null;
|
||||
}
|
||||
return token?.keyword && TABLE_CONSTRAINT_KEYWORDS.has(token.keyword) ? token.keyword : null;
|
||||
}
|
||||
|
||||
function readSqlToken(sql: string, start: number): SqlToken | null {
|
||||
let index = start;
|
||||
while (index < sql.length && /\s/u.test(sql[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
const char = sql[index];
|
||||
if (!char) {
|
||||
return null;
|
||||
}
|
||||
if (char === '"' || char === "`") {
|
||||
const end = skipSqlQuoted(sql, index, char);
|
||||
return { end, keyword: null, raw: sql.slice(index, end) };
|
||||
}
|
||||
if (char === "[") {
|
||||
const end = skipSqlQuoted(sql, index, char);
|
||||
return { end, keyword: null, raw: sql.slice(index, end) };
|
||||
}
|
||||
let end = index;
|
||||
while (end < sql.length && !/[\s(,]/u.test(sql[end] ?? "")) {
|
||||
end += 1;
|
||||
}
|
||||
const raw = sql.slice(index, end);
|
||||
return { end, keyword: raw.toUpperCase(), raw };
|
||||
}
|
||||
|
||||
function normalizeSqlIdentifier(identifier: string): string {
|
||||
if (identifier.startsWith('"') && identifier.endsWith('"')) {
|
||||
return identifier.slice(1, -1).replaceAll('""', '"').toLowerCase();
|
||||
}
|
||||
if (identifier.startsWith("`") && identifier.endsWith("`")) {
|
||||
return identifier.slice(1, -1).replaceAll("``", "`").toLowerCase();
|
||||
}
|
||||
if (identifier.startsWith("[") && identifier.endsWith("]")) {
|
||||
return identifier.slice(1, -1).toLowerCase();
|
||||
}
|
||||
return identifier.toLowerCase();
|
||||
}
|
||||
|
||||
function collectSqliteIndexContract(
|
||||
database: DatabaseSync,
|
||||
index: SqliteIndexListRow,
|
||||
@@ -611,161 +568,6 @@ function sqliteIndexTermKind(cid: number): SqliteIndexTermContract["kind"] {
|
||||
return cid === -2 ? "expression" : cid === -1 ? "rowid" : "column";
|
||||
}
|
||||
|
||||
function normalizeSchemaSql(sql: string | null): string | null {
|
||||
if (sql === null) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeSqlWhitespace(sql).replace(/;\s*$/u, "").trim();
|
||||
return normalized
|
||||
.replace(/^(CREATE TABLE) IF NOT EXISTS /iu, "$1 ")
|
||||
.replace(/^(CREATE VIRTUAL TABLE) IF NOT EXISTS /iu, "$1 ")
|
||||
.replace(/^(CREATE UNIQUE INDEX) IF NOT EXISTS /iu, "$1 ")
|
||||
.replace(/^(CREATE INDEX) IF NOT EXISTS /iu, "$1 ")
|
||||
.replace(/^(CREATE TRIGGER) IF NOT EXISTS /iu, "$1 ");
|
||||
}
|
||||
|
||||
function splitSqlList(sql: string): string[] {
|
||||
const items: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
let index = 0;
|
||||
while (index < sql.length) {
|
||||
const next = skipSqlQuotedOrComment(sql, index);
|
||||
if (next !== index) {
|
||||
index = next;
|
||||
continue;
|
||||
}
|
||||
const char = sql[index];
|
||||
if (char === "(") {
|
||||
depth += 1;
|
||||
} else if (char === ")") {
|
||||
depth -= 1;
|
||||
} else if (char === "," && depth === 0) {
|
||||
items.push(sql.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
items.push(sql.slice(start));
|
||||
return items;
|
||||
}
|
||||
|
||||
function findSqlCharacter(sql: string, character: string): number {
|
||||
let index = 0;
|
||||
while (index < sql.length) {
|
||||
const next = skipSqlQuotedOrComment(sql, index);
|
||||
if (next !== index) {
|
||||
index = next;
|
||||
continue;
|
||||
}
|
||||
if (sql[index] === character) {
|
||||
return index;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findSqlClosingParenthesis(sql: string, open: number): number {
|
||||
let depth = 0;
|
||||
let index = open;
|
||||
while (index < sql.length) {
|
||||
const next = skipSqlQuotedOrComment(sql, index);
|
||||
if (next !== index) {
|
||||
index = next;
|
||||
continue;
|
||||
}
|
||||
const char = sql[index];
|
||||
if (char === "(") {
|
||||
depth += 1;
|
||||
} else if (char === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
throw new Error("SQLite schema contains an unterminated table definition.");
|
||||
}
|
||||
|
||||
function normalizeSqlWhitespace(sql: string): string {
|
||||
let normalized = "";
|
||||
let pendingSpace = false;
|
||||
let index = 0;
|
||||
while (index < sql.length) {
|
||||
const quoted = skipSqlQuoted(sql, index, sql[index] ?? "");
|
||||
if (quoted !== index) {
|
||||
if (pendingSpace && normalized.length > 0) {
|
||||
normalized += " ";
|
||||
}
|
||||
normalized += sql.slice(index, quoted);
|
||||
pendingSpace = false;
|
||||
index = quoted;
|
||||
continue;
|
||||
}
|
||||
const comment = skipSqlComment(sql, index);
|
||||
if (comment !== index) {
|
||||
pendingSpace = true;
|
||||
index = comment;
|
||||
continue;
|
||||
}
|
||||
const char = sql[index] ?? "";
|
||||
if (/\s/u.test(char)) {
|
||||
pendingSpace = true;
|
||||
} else {
|
||||
if (pendingSpace && normalized.length > 0) {
|
||||
normalized += " ";
|
||||
}
|
||||
normalized += char;
|
||||
pendingSpace = false;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return normalized.trim();
|
||||
}
|
||||
|
||||
function skipSqlQuotedOrComment(sql: string, index: number): number {
|
||||
const quoted = skipSqlQuoted(sql, index, sql[index] ?? "");
|
||||
return quoted !== index ? quoted : skipSqlComment(sql, index);
|
||||
}
|
||||
|
||||
function skipSqlQuoted(sql: string, index: number, quote: string): number {
|
||||
if (quote !== "'" && quote !== '"' && quote !== "`" && quote !== "[") {
|
||||
return index;
|
||||
}
|
||||
const closingQuote = quote === "[" ? "]" : quote;
|
||||
let cursor = index + 1;
|
||||
while (cursor < sql.length) {
|
||||
if (sql[cursor] !== closingQuote) {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
if (quote !== "[" && sql[cursor + 1] === closingQuote) {
|
||||
cursor += 2;
|
||||
continue;
|
||||
}
|
||||
return cursor + 1;
|
||||
}
|
||||
return sql.length;
|
||||
}
|
||||
|
||||
function skipSqlComment(sql: string, index: number): number {
|
||||
if (sql.startsWith("--", index)) {
|
||||
const newline = sql.indexOf("\n", index + 2);
|
||||
return newline === -1 ? sql.length : newline + 1;
|
||||
}
|
||||
if (sql.startsWith("/*", index)) {
|
||||
const close = sql.indexOf("*/", index + 2);
|
||||
return close === -1 ? sql.length : close + 2;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function quoteSqliteIdentifier(identifier: string): string {
|
||||
return `"${identifier.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function isEqual(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
export type SqliteSchemaIssueCode =
|
||||
| "column-definition-drift"
|
||||
| "missing-column"
|
||||
| "missing-or-drifted-index"
|
||||
| "missing-or-drifted-trigger"
|
||||
| "missing-table"
|
||||
| "table-constraint-drift"
|
||||
| "table-definition-drift"
|
||||
| "table-options-drift"
|
||||
| "unexpected-column"
|
||||
| "unexpected-trigger"
|
||||
| "unexpected-unique-index"
|
||||
| "virtual-table-definition-drift";
|
||||
|
||||
export type SqliteSchemaIssue = {
|
||||
code: SqliteSchemaIssueCode;
|
||||
message: string;
|
||||
objectName: string;
|
||||
};
|
||||
|
||||
export type SqliteSchemaCompatibility = {
|
||||
/**
|
||||
* Canonical additive tables that may be absent until their owning feature
|
||||
* performs its one-time lazy ensure. Present tables still require the exact
|
||||
* canonical shape.
|
||||
*/
|
||||
allowedMissingTables?: readonly string[];
|
||||
/** Additive columns that may be absent until their owning feature lazily ensures them. */
|
||||
allowedMissingColumns?: readonly string[];
|
||||
/**
|
||||
* Exact definitions produced by supported additive migrations when SQLite
|
||||
* requires a temporary default that the clean schema does not retain.
|
||||
*/
|
||||
allowedColumnDefinitions?: Readonly<Record<string, readonly string[]>>;
|
||||
/**
|
||||
* Allow unexpected columns declared as a name plus one bare nullable SQLite
|
||||
* STRICT datatype. Allowed-missing tables remain exact when present.
|
||||
*/
|
||||
allowCompatibleAdditiveColumns?: boolean;
|
||||
/**
|
||||
* Exact owner-defined trigger groups that may be absent when their derived
|
||||
* or lazily ensured schema is absent, but must be complete and canonical
|
||||
* when present.
|
||||
*/
|
||||
optionalCanonicalTriggerGroups?: readonly {
|
||||
/** The trigger group is optional only while this canonical table is absent. */
|
||||
optionalWhenTableMissing?: string;
|
||||
tableName: string;
|
||||
triggers: readonly {
|
||||
name: string;
|
||||
sql: string;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
function defaultIssueMessage(code: SqliteSchemaIssueCode, objectName: string): string {
|
||||
const tableName = objectName.split(".", 1)[0];
|
||||
switch (code) {
|
||||
case "missing-table":
|
||||
return `missing table ${objectName}`;
|
||||
case "missing-column":
|
||||
case "unexpected-column":
|
||||
case "column-definition-drift":
|
||||
return `column definitions differ for ${tableName}`;
|
||||
case "table-constraint-drift":
|
||||
return `table constraints differ for ${objectName}`;
|
||||
case "table-definition-drift":
|
||||
return `table definition differs for ${objectName}`;
|
||||
case "missing-or-drifted-index":
|
||||
return `missing or drifted index ${objectName}`;
|
||||
case "unexpected-unique-index":
|
||||
return `unexpected unique index ${objectName}`;
|
||||
case "missing-or-drifted-trigger":
|
||||
return `missing or drifted trigger ${objectName}`;
|
||||
case "unexpected-trigger":
|
||||
return `unexpected trigger ${objectName}`;
|
||||
case "virtual-table-definition-drift":
|
||||
return `virtual table definition differs for ${objectName}`;
|
||||
case "table-options-drift":
|
||||
return `table options differ for ${objectName}`;
|
||||
}
|
||||
const exhaustiveCode: never = code;
|
||||
throw new Error("Unsupported SQLite schema issue code", { cause: exhaustiveCode });
|
||||
}
|
||||
|
||||
export function createSqliteSchemaIssue(
|
||||
code: SqliteSchemaIssueCode,
|
||||
objectName: string,
|
||||
message?: string,
|
||||
): SqliteSchemaIssue {
|
||||
return { code, objectName, message: message ?? defaultIssueMessage(code, objectName) };
|
||||
}
|
||||
|
||||
export function legacySqliteSchemaIssueMessages(issues: readonly SqliteSchemaIssue[]): string[] {
|
||||
const isColumnIssue = (issue: SqliteSchemaIssue) =>
|
||||
issue.code === "column-definition-drift" ||
|
||||
issue.code === "missing-column" ||
|
||||
issue.code === "unexpected-column";
|
||||
const columnIssueTables = new Set(
|
||||
issues.filter(isColumnIssue).map((issue) => issue.objectName.split(".", 1)[0]),
|
||||
);
|
||||
return [
|
||||
...new Set(
|
||||
issues
|
||||
.filter(
|
||||
(issue) =>
|
||||
issue.code !== "table-constraint-drift" || !columnIssueTables.has(issue.objectName),
|
||||
)
|
||||
.map((issue) => issue.message),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function throwSqliteSchemaMismatches(
|
||||
databaseLabel: string,
|
||||
mismatches: readonly string[],
|
||||
): never {
|
||||
const shown = mismatches.slice(0, 8);
|
||||
if (mismatches.length > shown.length) {
|
||||
shown.push(`${mismatches.length - shown.length} additional mismatch(es)`);
|
||||
}
|
||||
throw new Error(
|
||||
`SQLite schema is incomplete or noncanonical for ${databaseLabel}: ${shown.join("; ")}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
const TABLE_CONSTRAINT_KEYWORDS = new Set(["CHECK", "FOREIGN", "PRIMARY", "UNIQUE"]);
|
||||
|
||||
type SqlToken = {
|
||||
end: number;
|
||||
keyword: string | null;
|
||||
raw: string;
|
||||
};
|
||||
|
||||
export function readTableConstraintKeyword(sql: string, first: SqlToken): string | null {
|
||||
let token: SqlToken | null = first;
|
||||
if (token.keyword === "CONSTRAINT") {
|
||||
const name = readSqlToken(sql, token.end);
|
||||
token = name ? readSqlToken(sql, name.end) : null;
|
||||
}
|
||||
return token?.keyword && TABLE_CONSTRAINT_KEYWORDS.has(token.keyword) ? token.keyword : null;
|
||||
}
|
||||
|
||||
export function readSqlToken(sql: string, start: number): SqlToken | null {
|
||||
let index = start;
|
||||
while (index < sql.length && /\s/u.test(sql[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
const char = sql[index];
|
||||
if (!char) {
|
||||
return null;
|
||||
}
|
||||
if (char === '"' || char === "`") {
|
||||
const end = skipSqlQuoted(sql, index, char);
|
||||
return { end, keyword: null, raw: sql.slice(index, end) };
|
||||
}
|
||||
if (char === "[") {
|
||||
const end = skipSqlQuoted(sql, index, char);
|
||||
return { end, keyword: null, raw: sql.slice(index, end) };
|
||||
}
|
||||
let end = index;
|
||||
while (end < sql.length && !/[\s(,]/u.test(sql[end] ?? "")) {
|
||||
end += 1;
|
||||
}
|
||||
const raw = sql.slice(index, end);
|
||||
return { end, keyword: raw.toUpperCase(), raw };
|
||||
}
|
||||
|
||||
export function normalizeSqlIdentifier(identifier: string): string {
|
||||
if (identifier.startsWith('"') && identifier.endsWith('"')) {
|
||||
return identifier.slice(1, -1).replaceAll('""', '"').toLowerCase();
|
||||
}
|
||||
if (identifier.startsWith("`") && identifier.endsWith("`")) {
|
||||
return identifier.slice(1, -1).replaceAll("``", "`").toLowerCase();
|
||||
}
|
||||
if (identifier.startsWith("[") && identifier.endsWith("]")) {
|
||||
return identifier.slice(1, -1).toLowerCase();
|
||||
}
|
||||
return identifier.toLowerCase();
|
||||
}
|
||||
|
||||
export function normalizeSchemaSql(sql: string | null): string | null {
|
||||
if (sql === null) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeSqlWhitespace(sql).replace(/;\s*$/u, "").trim();
|
||||
return normalized
|
||||
.replace(/^(CREATE TABLE) IF NOT EXISTS /iu, "$1 ")
|
||||
.replace(/^(CREATE VIRTUAL TABLE) IF NOT EXISTS /iu, "$1 ")
|
||||
.replace(/^(CREATE UNIQUE INDEX) IF NOT EXISTS /iu, "$1 ")
|
||||
.replace(/^(CREATE INDEX) IF NOT EXISTS /iu, "$1 ")
|
||||
.replace(/^(CREATE TRIGGER) IF NOT EXISTS /iu, "$1 ");
|
||||
}
|
||||
|
||||
export function splitSqlList(sql: string): string[] {
|
||||
const items: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
let index = 0;
|
||||
while (index < sql.length) {
|
||||
const next = skipSqlQuotedOrComment(sql, index);
|
||||
if (next !== index) {
|
||||
index = next;
|
||||
continue;
|
||||
}
|
||||
const char = sql[index];
|
||||
if (char === "(") {
|
||||
depth += 1;
|
||||
} else if (char === ")") {
|
||||
depth -= 1;
|
||||
} else if (char === "," && depth === 0) {
|
||||
items.push(sql.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
items.push(sql.slice(start));
|
||||
return items;
|
||||
}
|
||||
|
||||
export function findSqlCharacter(sql: string, character: string): number {
|
||||
let index = 0;
|
||||
while (index < sql.length) {
|
||||
const next = skipSqlQuotedOrComment(sql, index);
|
||||
if (next !== index) {
|
||||
index = next;
|
||||
continue;
|
||||
}
|
||||
if (sql[index] === character) {
|
||||
return index;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function findSqlClosingParenthesis(sql: string, open: number): number {
|
||||
let depth = 0;
|
||||
let index = open;
|
||||
while (index < sql.length) {
|
||||
const next = skipSqlQuotedOrComment(sql, index);
|
||||
if (next !== index) {
|
||||
index = next;
|
||||
continue;
|
||||
}
|
||||
const char = sql[index];
|
||||
if (char === "(") {
|
||||
depth += 1;
|
||||
} else if (char === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
throw new Error("SQLite schema contains an unterminated table definition.");
|
||||
}
|
||||
|
||||
export function normalizeSqlWhitespace(sql: string): string {
|
||||
let normalized = "";
|
||||
let pendingSpace = false;
|
||||
let index = 0;
|
||||
while (index < sql.length) {
|
||||
const quoted = skipSqlQuoted(sql, index, sql[index] ?? "");
|
||||
if (quoted !== index) {
|
||||
if (pendingSpace && normalized.length > 0) {
|
||||
normalized += " ";
|
||||
}
|
||||
normalized += sql.slice(index, quoted);
|
||||
pendingSpace = false;
|
||||
index = quoted;
|
||||
continue;
|
||||
}
|
||||
const comment = skipSqlComment(sql, index);
|
||||
if (comment !== index) {
|
||||
pendingSpace = true;
|
||||
index = comment;
|
||||
continue;
|
||||
}
|
||||
const char = sql[index] ?? "";
|
||||
if (/\s/u.test(char)) {
|
||||
pendingSpace = true;
|
||||
} else {
|
||||
if (pendingSpace && normalized.length > 0) {
|
||||
normalized += " ";
|
||||
}
|
||||
normalized += char;
|
||||
pendingSpace = false;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return normalized.trim();
|
||||
}
|
||||
|
||||
export function quoteSqliteIdentifier(identifier: string): string {
|
||||
return `"${identifier.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function skipSqlQuotedOrComment(sql: string, index: number): number {
|
||||
const quoted = skipSqlQuoted(sql, index, sql[index] ?? "");
|
||||
return quoted !== index ? quoted : skipSqlComment(sql, index);
|
||||
}
|
||||
|
||||
function skipSqlQuoted(sql: string, index: number, quote: string): number {
|
||||
if (quote !== "'" && quote !== '"' && quote !== "`" && quote !== "[") {
|
||||
return index;
|
||||
}
|
||||
const closingQuote = quote === "[" ? "]" : quote;
|
||||
let cursor = index + 1;
|
||||
while (cursor < sql.length) {
|
||||
if (sql[cursor] !== closingQuote) {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
if (quote !== "[" && sql[cursor + 1] === closingQuote) {
|
||||
cursor += 2;
|
||||
continue;
|
||||
}
|
||||
return cursor + 1;
|
||||
}
|
||||
return sql.length;
|
||||
}
|
||||
|
||||
function skipSqlComment(sql: string, index: number): number {
|
||||
if (sql.startsWith("--", index)) {
|
||||
const newline = sql.indexOf("\n", index + 2);
|
||||
return newline === -1 ? sql.length : newline + 1;
|
||||
}
|
||||
if (sql.startsWith("/*", index)) {
|
||||
const close = sql.indexOf("*/", index + 2);
|
||||
return close === -1 ? sql.length : close + 2;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Startup migration checkpoint tests cover shared-state version records and leases.
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import {
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
withOpenClawStateStartupMigrationCheckpointDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import {
|
||||
OpenClawStateOwnershipError,
|
||||
STATE_SUPERVISION_KEY,
|
||||
} from "../state/openclaw-state-ownership.js";
|
||||
import {
|
||||
executeSqliteQuerySync,
|
||||
executeSqliteQueryTakeFirstSync,
|
||||
@@ -31,6 +35,7 @@ import {
|
||||
|
||||
afterEach(() => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const startupMigrationTempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
@@ -268,6 +273,85 @@ describe("startup migration checkpoint", () => {
|
||||
next.release();
|
||||
});
|
||||
|
||||
it("rechecks external ownership inside the final lease write transaction", () => {
|
||||
const env = {
|
||||
OPENCLAW_STATE_DIR: startupMigrationTempDirs.make("openclaw-startup-migration-"),
|
||||
};
|
||||
runOpenClawStateWriteTransaction(() => undefined, { env });
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const databasePath = resolveOpenClawStateSqlitePath(env);
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const originalExec = Object.getOwnPropertyDescriptor(DatabaseSync.prototype, "exec")?.value as
|
||||
| ((this: import("node:sqlite").DatabaseSync, sql: string) => void)
|
||||
| undefined;
|
||||
if (!originalExec) {
|
||||
throw new Error("DatabaseSync.exec descriptor is unavailable");
|
||||
}
|
||||
// Schema setup commits before the lease helper starts its own transaction.
|
||||
// Claim at that exact boundary so the final transaction must fence the new owner.
|
||||
let immediateTransactionCount = 0;
|
||||
const exec = vi.spyOn(DatabaseSync.prototype, "exec").mockImplementation(function (
|
||||
this: import("node:sqlite").DatabaseSync,
|
||||
sql: string,
|
||||
) {
|
||||
if (sql === "BEGIN IMMEDIATE" && ++immediateTransactionCount === 2) {
|
||||
const claimant = new DatabaseSync(databasePath);
|
||||
try {
|
||||
claimant
|
||||
.prepare(
|
||||
`INSERT INTO config_machine_state (state_key, value_json, updated_at_ms)
|
||||
VALUES (?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
STATE_SUPERVISION_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
mode: "external",
|
||||
managerId: "race-manager",
|
||||
claimedAt: 1,
|
||||
}),
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
claimant.close();
|
||||
}
|
||||
}
|
||||
return originalExec.call(this, sql);
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => acquireStartupMigrationLease({ env, owner: "unmarked", nowMs: 1 })).toThrow(
|
||||
OpenClawStateOwnershipError,
|
||||
);
|
||||
} finally {
|
||||
exec.mockRestore();
|
||||
}
|
||||
|
||||
const verify = new DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
expect(
|
||||
verify
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM state_leases
|
||||
WHERE scope = 'startup-migrations' AND lease_key = 'global'`,
|
||||
)
|
||||
.get(),
|
||||
).toEqual({ count: 0 });
|
||||
expect(
|
||||
verify
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM schema_meta
|
||||
WHERE meta_key IN ('state-migrations', 'startup-migrations')`,
|
||||
)
|
||||
.get(),
|
||||
).toEqual({ count: 0 });
|
||||
} finally {
|
||||
verify.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("waits for a live same-host startup migration lease to be released", async () => {
|
||||
const env = {
|
||||
OPENCLAW_STATE_DIR: startupMigrationTempDirs.make("openclaw-startup-migration-"),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { withOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-re
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import { withOpenClawStateStartupMigrationCheckpointDatabase } from "../state/openclaw-state-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import { assertOpenClawStateWriteAllowed } from "../state/openclaw-state-ownership.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import {
|
||||
executeSqliteQuerySync,
|
||||
@@ -156,8 +157,12 @@ function writeStartupMigrationCheckpointDatabase<T>(
|
||||
env: NodeJS.ProcessEnv,
|
||||
callback: (db: DatabaseSync) => T,
|
||||
): T {
|
||||
const databasePath = resolveOpenClawStateSqlitePath(env);
|
||||
return withStartupMigrationCheckpointDatabase(env, (db) =>
|
||||
runSqliteImmediateTransactionSync(db, () => callback(db)),
|
||||
runSqliteImmediateTransactionSync(db, () => {
|
||||
assertOpenClawStateWriteAllowed({ database: db, databasePath, env });
|
||||
return callback(db);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Tests externally owned state behavior in the detached managed-service update helper.
|
||||
*/
|
||||
import { EventEmitter } from "node:events";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import { claimOpenClawStateOwnership } from "../state/openclaw-state-ownership-operations.js";
|
||||
import { executeSqliteQuerySync, getNodeSqliteKysely } from "./kysely-sync.js";
|
||||
|
||||
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));
|
||||
|
||||
function createSpawnMock() {
|
||||
return Object.assign(new EventEmitter(), {
|
||||
pid: 24680,
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
stdout: new PassThrough(),
|
||||
unref: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const { mockNodeChildProcessModule } =
|
||||
await import("../gateway/server-methods/node-child-process.test-support.js");
|
||||
return mockNodeChildProcessModule({
|
||||
spawn: spawnMock as unknown as typeof import("node:child_process").spawn,
|
||||
});
|
||||
});
|
||||
|
||||
const tempDirs = new Set<string>();
|
||||
type GatewayRestartSentinelDatabase = Pick<OpenClawStateKyselyDatabase, "gateway_restart_sentinel">;
|
||||
|
||||
beforeEach(() => {
|
||||
spawnMock.mockReset();
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = createSpawnMock();
|
||||
process.nextTick(() => {
|
||||
child.stdout.write("OPENCLAW_UPDATE_HANDOFF_READY\n");
|
||||
});
|
||||
return child;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
await Promise.all([...tempDirs].map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
tempDirs.clear();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
function writeRestartSentinelRow(
|
||||
env: NodeJS.ProcessEnv,
|
||||
sentinel: {
|
||||
version: 1;
|
||||
revision: number;
|
||||
payload: {
|
||||
kind: string;
|
||||
status: string;
|
||||
ts: number;
|
||||
stats: Record<string, unknown>;
|
||||
};
|
||||
},
|
||||
): void {
|
||||
const { db } = openOpenClawStateDatabase({ env });
|
||||
const stateDb = getNodeSqliteKysely<GatewayRestartSentinelDatabase>(db);
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
stateDb.insertInto("gateway_restart_sentinel").values({
|
||||
sentinel_key: "current",
|
||||
version: sentinel.version,
|
||||
kind: sentinel.payload.kind,
|
||||
status: sentinel.payload.status,
|
||||
ts: sentinel.payload.ts,
|
||||
session_key: null,
|
||||
thread_id: null,
|
||||
delivery_channel: null,
|
||||
delivery_to: null,
|
||||
delivery_account_id: null,
|
||||
message: null,
|
||||
continuation_json: null,
|
||||
doctor_hint: null,
|
||||
stats_json: JSON.stringify(sentinel.payload.stats),
|
||||
payload_json: JSON.stringify(sentinel.payload),
|
||||
updated_at_ms: sentinel.revision,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function runOwnershipHelper(params: {
|
||||
handoffId?: string;
|
||||
metaHandoffId?: string;
|
||||
prepareStateDatabase: (env: NodeJS.ProcessEnv) => Promise<void> | void;
|
||||
whileHelperRunning?: (context: { logPath: string }) => Promise<void> | void;
|
||||
}) {
|
||||
const { execFile } =
|
||||
await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
const { startManagedServiceUpdateHandoff } = await import("./update-managed-service-handoff.js");
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-handoff-ownership-test-"));
|
||||
tempDirs.add(tmpDir);
|
||||
const env = { OPENCLAW_STATE_DIR: tmpDir } as NodeJS.ProcessEnv;
|
||||
|
||||
await startManagedServiceUpdateHandoff({
|
||||
root: tmpDir,
|
||||
timeoutMs: 1_800_000,
|
||||
restartDrainTimeoutMs: 300_000,
|
||||
restartDelayMs: 500,
|
||||
parentPid: process.pid,
|
||||
execPath: "/usr/local/bin/node",
|
||||
argv1: "/opt/openclaw/openclaw.mjs",
|
||||
...(params.handoffId ? { handoffId: params.handoffId } : {}),
|
||||
env,
|
||||
meta: {
|
||||
...(params.metaHandoffId ? { handoffId: params.metaHandoffId } : {}),
|
||||
sessionKey: "agent:test:webchat:dm:user-123",
|
||||
continuationMessage: "continue after restart",
|
||||
},
|
||||
});
|
||||
|
||||
const [, args, spawnOptions] = spawnMock.mock.calls.at(-1) as unknown as [
|
||||
string,
|
||||
string[],
|
||||
{ env: NodeJS.ProcessEnv; detached?: boolean; cwd?: string },
|
||||
];
|
||||
const helperScriptPath = args[0] ?? "";
|
||||
tempDirs.add(path.dirname(helperScriptPath));
|
||||
const helperParams = JSON.parse(await fs.readFile(args[1] ?? "", "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
await params.prepareStateDatabase(env);
|
||||
const helperParamsPath = path.join(tmpDir, "helper-params.json");
|
||||
const logPath = path.join(tmpDir, "handoff.log");
|
||||
await fs.writeFile(
|
||||
helperParamsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
...helperParams,
|
||||
parentPid: process.pid,
|
||||
parentExitTimeoutMs: 1,
|
||||
logPath,
|
||||
sensitivePaths: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const resultPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
|
||||
(resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[helperScriptPath, helperParamsPath],
|
||||
{ cwd: tmpDir, env: spawnOptions.env },
|
||||
(err) => {
|
||||
const childError = err as (NodeJS.ErrnoException & { signal?: NodeJS.Signals }) | null;
|
||||
resolve({
|
||||
code: typeof childError?.code === "number" ? childError.code : 0,
|
||||
signal: childError?.signal ?? null,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
await params.whileHelperRunning?.({ logPath });
|
||||
return { result: await resultPromise, env, logPath };
|
||||
}
|
||||
|
||||
describe("managed service update handoff external ownership", () => {
|
||||
it("refuses fallback writes to externally owned state without the supervisor marker", async () => {
|
||||
let before:
|
||||
| {
|
||||
bytes: Buffer;
|
||||
entries: string[];
|
||||
ctimeMs: number;
|
||||
ino: number;
|
||||
mode: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
| undefined;
|
||||
const { result, env, logPath } = await runOwnershipHelper({
|
||||
prepareStateDatabase: async (stateEnv) => {
|
||||
const externalEnv = { ...stateEnv, OPENCLAW_SUPERVISOR_MODE: "external" };
|
||||
claimOpenClawStateOwnership("gateway-supervisor", { env: externalEnv });
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const databasePath = resolveOpenClawStateSqlitePath(stateEnv);
|
||||
const stat = await fs.stat(databasePath);
|
||||
before = {
|
||||
bytes: await fs.readFile(databasePath),
|
||||
entries: (await fs.readdir(path.dirname(databasePath))).toSorted(),
|
||||
ctimeMs: stat.ctimeMs,
|
||||
ino: stat.ino,
|
||||
mode: stat.mode,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ code: 1, signal: null });
|
||||
const databasePath = resolveOpenClawStateSqlitePath(env);
|
||||
const stat = await fs.stat(databasePath);
|
||||
expect({
|
||||
bytes: await fs.readFile(databasePath),
|
||||
entries: (await fs.readdir(path.dirname(databasePath))).toSorted(),
|
||||
ctimeMs: stat.ctimeMs,
|
||||
ino: stat.ino,
|
||||
mode: stat.mode,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
}).toEqual(before);
|
||||
await expect(fs.readFile(logPath, "utf8")).resolves.toMatch(
|
||||
/gateway-supervisor.*OPENCLAW_SUPERVISOR_MODE=external/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("rechecks external ownership after waiting for the state write lock", async () => {
|
||||
const pendingSentinel = {
|
||||
version: 1 as const,
|
||||
revision: 100,
|
||||
payload: {
|
||||
kind: "update",
|
||||
status: "skipped",
|
||||
ts: 100,
|
||||
stats: {
|
||||
handoffId: "handoff-ownership-race",
|
||||
reason: "managed-service-handoff-started",
|
||||
},
|
||||
},
|
||||
};
|
||||
const ownership = {
|
||||
version: 1,
|
||||
mode: "external",
|
||||
managerId: "race-supervisor",
|
||||
claimedAt: Date.now(),
|
||||
};
|
||||
let claimant: import("node:sqlite").DatabaseSync | undefined;
|
||||
let claimantTransactionOpen = false;
|
||||
let beforeSentinelRow: unknown;
|
||||
let helperResult: Awaited<ReturnType<typeof runOwnershipHelper>> | undefined;
|
||||
try {
|
||||
helperResult = await runOwnershipHelper({
|
||||
handoffId: "handoff-ownership-race",
|
||||
metaHandoffId: "handoff-ownership-race",
|
||||
prepareStateDatabase: async (stateEnv) => {
|
||||
writeRestartSentinelRow(stateEnv, pendingSentinel);
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const sqlite = await import("node:sqlite");
|
||||
claimant = new sqlite.DatabaseSync(resolveOpenClawStateSqlitePath(stateEnv));
|
||||
claimant.exec("BEGIN IMMEDIATE;");
|
||||
claimantTransactionOpen = true;
|
||||
claimant
|
||||
.prepare(
|
||||
"INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run("gateway.supervision", JSON.stringify(ownership), ownership.claimedAt);
|
||||
beforeSentinelRow = claimant
|
||||
.prepare("SELECT * FROM gateway_restart_sentinel WHERE sentinel_key = ?")
|
||||
.get("current");
|
||||
},
|
||||
whileHelperRunning: async ({ logPath }) => {
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
await expect(fs.readFile(logPath, "utf8")).resolves.toContain(
|
||||
"did not exit before handoff timeout",
|
||||
);
|
||||
},
|
||||
{ interval: 5, timeout: 2_000 },
|
||||
);
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
if (!claimant) {
|
||||
throw new Error("expected the ownership claimant transaction to remain open");
|
||||
}
|
||||
claimant.exec("COMMIT;");
|
||||
claimantTransactionOpen = false;
|
||||
claimant.close();
|
||||
claimant = undefined;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (claimantTransactionOpen) {
|
||||
try {
|
||||
claimant?.exec("ROLLBACK;");
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
claimant?.close();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!helperResult) {
|
||||
throw new Error("expected the detached helper to return a result");
|
||||
}
|
||||
expect(helperResult.result).toEqual({ code: 1, signal: null });
|
||||
const databasePath = resolveOpenClawStateSqlitePath(helperResult.env);
|
||||
const sqlite = await import("node:sqlite");
|
||||
const verifyDb = new sqlite.DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
const ownershipRow = verifyDb
|
||||
.prepare("SELECT value_json FROM config_machine_state WHERE state_key = ?")
|
||||
.get("gateway.supervision") as { value_json?: unknown } | undefined;
|
||||
expect(ownershipRow?.value_json).toBe(JSON.stringify(ownership));
|
||||
expect(
|
||||
verifyDb
|
||||
.prepare("SELECT * FROM gateway_restart_sentinel WHERE sentinel_key = ?")
|
||||
.get("current"),
|
||||
).toEqual(beforeSentinelRow);
|
||||
} finally {
|
||||
verifyDb.close();
|
||||
}
|
||||
await expect(fs.readFile(helperResult.logPath, "utf8")).resolves.toMatch(
|
||||
/race-supervisor.*OPENCLAW_SUPERVISOR_MODE=external/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,7 @@ const { spawn, spawnSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
|
||||
const params = JSON.parse(fs.readFileSync(process.argv[2], "utf-8"));
|
||||
|
||||
@@ -117,15 +118,96 @@ function isPendingUpdatePayload(payload) {
|
||||
);
|
||||
}
|
||||
|
||||
// Keep this self-contained helper aligned with resolveImmutableSqliteFileUri;
|
||||
// the detached script cannot import the TypeScript runtime after replacement.
|
||||
function resolveImmutableStateDatabaseUri(databasePath) {
|
||||
if (process.platform === "win32") {
|
||||
const namespacedPath = path.toNamespacedPath(path.resolve(databasePath));
|
||||
return "file:" + encodeURIComponent(namespacedPath) + "?mode=ro&immutable=1";
|
||||
}
|
||||
return pathToFileURL(path.resolve(databasePath)).href + "?mode=ro&immutable=1";
|
||||
}
|
||||
|
||||
function assertStateDatabaseWriteAllowed(database) {
|
||||
if (
|
||||
!params.stateDatabasePath ||
|
||||
typeof params.stateDatabasePath !== "string" ||
|
||||
(!database && !fs.existsSync(params.stateDatabasePath))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const ownsDatabase = !database;
|
||||
let db = database;
|
||||
if (!db) {
|
||||
const sqlite = require("node:sqlite");
|
||||
db = new sqlite.DatabaseSync(resolveImmutableStateDatabaseUri(params.stateDatabasePath), {
|
||||
readOnly: true,
|
||||
});
|
||||
}
|
||||
try {
|
||||
if (ownsDatabase) {
|
||||
db.exec("PRAGMA query_only = ON; PRAGMA trusted_schema = OFF;");
|
||||
}
|
||||
const table = db
|
||||
.prepare("SELECT 1 FROM main.sqlite_schema WHERE type = 'table' AND name = 'config_machine_state' LIMIT 1")
|
||||
.get();
|
||||
if (!table) return;
|
||||
const row = db
|
||||
.prepare("SELECT value_json FROM config_machine_state WHERE state_key = 'gateway.supervision' LIMIT 1")
|
||||
.get();
|
||||
if (!row) return;
|
||||
let value = null;
|
||||
if (typeof row.value_json === "string") {
|
||||
try {
|
||||
value = JSON.parse(row.value_json);
|
||||
} catch {
|
||||
// The shared owner contract below rejects invalid JSON and shape together.
|
||||
}
|
||||
}
|
||||
const keys = value && typeof value === "object" && !Array.isArray(value)
|
||||
? Object.keys(value).sort()
|
||||
: [];
|
||||
if (
|
||||
keys.join(",") !== "claimedAt,managerId,mode,version" ||
|
||||
value.version !== 1 ||
|
||||
value.mode !== "external" ||
|
||||
typeof value.managerId !== "string" ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value.managerId) ||
|
||||
!Number.isSafeInteger(value.claimedAt) ||
|
||||
value.claimedAt < 0 ||
|
||||
value.claimedAt > 8640000000000000
|
||||
) {
|
||||
throw new Error("shared-state ownership metadata is malformed");
|
||||
}
|
||||
if ((process.env.OPENCLAW_SUPERVISOR_MODE || "").trim().toLowerCase() !== "external") {
|
||||
throw new Error(
|
||||
"shared state is externally supervised by " +
|
||||
value.managerId +
|
||||
"; use that external supervisor with OPENCLAW_SUPERVISOR_MODE=external",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (ownsDatabase) {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openStateDatabase() {
|
||||
if (!params.stateDatabasePath || typeof params.stateDatabasePath !== "string") {
|
||||
return null;
|
||||
}
|
||||
let db = null;
|
||||
let transactionOpen = false;
|
||||
try {
|
||||
assertStateDatabaseWriteAllowed();
|
||||
const sqlite = require("node:sqlite");
|
||||
fs.mkdirSync(path.dirname(params.stateDatabasePath), { recursive: true, mode: 0o700 });
|
||||
const db = new sqlite.DatabaseSync(params.nodeSqliteLocation);
|
||||
db = new sqlite.DatabaseSync(params.nodeSqliteLocation);
|
||||
db.exec("PRAGMA busy_timeout = ${HANDOFF_STATE_DATABASE_BUSY_TIMEOUT_MS};");
|
||||
db.exec("BEGIN IMMEDIATE;");
|
||||
transactionOpen = true;
|
||||
assertStateDatabaseWriteAllowed(db);
|
||||
db.exec([
|
||||
"CREATE TABLE IF NOT EXISTS gateway_restart_sentinel (",
|
||||
"sentinel_key TEXT NOT NULL PRIMARY KEY,",
|
||||
@@ -150,8 +232,18 @@ function openStateDatabase() {
|
||||
].join(" "));
|
||||
ensureGatewayRestartSentinelColumns(db);
|
||||
hardenStateDatabaseFiles();
|
||||
db.exec("COMMIT;");
|
||||
transactionOpen = false;
|
||||
return db;
|
||||
} catch (err) {
|
||||
if (transactionOpen) {
|
||||
try {
|
||||
db.exec("ROLLBACK;");
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
db?.close();
|
||||
} catch {}
|
||||
appendLog("failed to open restart sentinel database: " + (err && err.stack ? err.stack : String(err)));
|
||||
return null;
|
||||
}
|
||||
@@ -389,6 +481,7 @@ function markUpdateSentinelFailureIfPending(reason) {
|
||||
try {
|
||||
db.exec("BEGIN IMMEDIATE;");
|
||||
transactionOpen = true;
|
||||
assertStateDatabaseWriteAllowed(db);
|
||||
const current = readRestartSentinelRecord(db);
|
||||
if (
|
||||
(snapshot === null && current !== null) ||
|
||||
@@ -435,7 +528,6 @@ function markUpdateSentinelFailureIfPending(reason) {
|
||||
}
|
||||
appendLog("failed to write update sentinel failure: " + (err && err.stack ? err.stack : String(err)));
|
||||
} finally {
|
||||
hardenStateDatabaseFiles();
|
||||
try {
|
||||
db.close();
|
||||
} catch {}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js";
|
||||
import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { claimOpenClawStateOwnership } from "../state/openclaw-state-ownership-operations.js";
|
||||
import { OpenClawStateOwnershipError } from "../state/openclaw-state-ownership.js";
|
||||
import {
|
||||
acquireDebugProxyCaptureStore,
|
||||
closeDebugProxyCaptureStore,
|
||||
@@ -77,6 +79,24 @@ describe("DebugProxyCaptureStore", () => {
|
||||
expect(() => rebound.endSession("exit-session")).not.toThrow();
|
||||
});
|
||||
|
||||
it("fences a shared store that was opened before external ownership was claimed", () => {
|
||||
const env = makeStateEnv("openclaw-proxy-capture-preclaim-");
|
||||
const store = new DebugProxyCaptureStore({ env });
|
||||
env.OPENCLAW_SUPERVISOR_MODE = "external";
|
||||
claimOpenClawStateOwnership("gateway-supervisor", { env });
|
||||
delete env.OPENCLAW_SUPERVISOR_MODE;
|
||||
|
||||
expect(() =>
|
||||
store.upsertSession({
|
||||
id: "preclaim-session",
|
||||
startedAt: 1,
|
||||
mode: "proxy-run",
|
||||
sourceScope: "openclaw",
|
||||
sourceProcess: "cli",
|
||||
}),
|
||||
).toThrow(OpenClawStateOwnershipError);
|
||||
});
|
||||
|
||||
it("tracks and closes cached stores independently across paths", () => {
|
||||
const first = acquireDebugProxyCaptureStore({
|
||||
env: makeStateEnv("openclaw-proxy-capture-first-"),
|
||||
@@ -99,6 +119,14 @@ describe("DebugProxyCaptureStore", () => {
|
||||
const dbPath = path.join(root, "capture.sqlite");
|
||||
const blobDir = path.join(root, "blobs");
|
||||
const lease = acquireDebugProxyCaptureStore(dbPath, blobDir);
|
||||
lease.store.db.exec(`
|
||||
CREATE TABLE config_machine_state (
|
||||
state_key TEXT PRIMARY KEY,
|
||||
value_json TEXT NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO config_machine_state VALUES ('gateway.supervision', '{"malformed":true}', 1);
|
||||
`);
|
||||
|
||||
expect(getDebugProxyCaptureStore(dbPath, blobDir)).toBe(lease.store);
|
||||
lease.store.upsertSession({
|
||||
@@ -138,6 +166,7 @@ describe("DebugProxyCaptureStore", () => {
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'capture_blobs'")
|
||||
.get(),
|
||||
).toBeUndefined();
|
||||
lease.store.db.exec("DROP TABLE config_machine_state;");
|
||||
expect(
|
||||
lease.store.db
|
||||
.prepare(
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
registerSqliteCacheExitClose,
|
||||
type SqliteWalMaintenance,
|
||||
} from "../infra/sqlite-wal.js";
|
||||
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
type OpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import type {
|
||||
CaptureBlobRecord,
|
||||
CaptureEventRecord,
|
||||
@@ -197,6 +201,24 @@ function sortObservedCounts(counts: Map<string, number>): CaptureObservedDimensi
|
||||
.toSorted((left, right) => right.count - left.count || left.value.localeCompare(right.value));
|
||||
}
|
||||
|
||||
type SharedDebugProxyCaptureState = {
|
||||
database: OpenClawStateDatabase;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
const sharedDebugProxyCaptureStates = new WeakMap<object, SharedDebugProxyCaptureState>();
|
||||
|
||||
function runSharedDebugProxyCaptureWrite<T>(owner: object, operation: () => T): T {
|
||||
const shared = sharedDebugProxyCaptureStates.get(owner);
|
||||
if (!shared) {
|
||||
throw new Error("shared debug proxy capture state is unavailable");
|
||||
}
|
||||
return runOpenClawStateWriteTransaction(() => operation(), {
|
||||
database: shared.database,
|
||||
env: shared.env ?? process.env,
|
||||
});
|
||||
}
|
||||
|
||||
class DebugProxyCaptureStoreImpl {
|
||||
readonly db: DatabaseSync;
|
||||
readonly dbPath: string;
|
||||
@@ -220,6 +242,7 @@ class DebugProxyCaptureStoreImpl {
|
||||
return;
|
||||
}
|
||||
const database = openOpenClawStateDatabase({ env: optionsOrDbPath.env });
|
||||
sharedDebugProxyCaptureStates.set(this, { database, env: optionsOrDbPath.env });
|
||||
this.db = database.db;
|
||||
this.dbPath = database.path;
|
||||
// Retain the shipped public property while shared-state blobs live in this DB.
|
||||
@@ -269,36 +292,44 @@ class DebugProxyCaptureStoreImpl {
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO capture_sessions (
|
||||
id, started_at, ended_at, mode, source_scope, source_process, proxy_url
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
started_at=MIN(capture_sessions.started_at, excluded.started_at),
|
||||
ended_at=excluded.ended_at,
|
||||
mode=CASE
|
||||
WHEN capture_sessions.mode = 'implicit' THEN excluded.mode
|
||||
ELSE capture_sessions.mode
|
||||
END,
|
||||
proxy_url=excluded.proxy_url,
|
||||
source_process=excluded.source_process`,
|
||||
)
|
||||
.run(
|
||||
session.id,
|
||||
session.startedAt,
|
||||
session.endedAt ?? null,
|
||||
session.mode,
|
||||
session.sourceScope,
|
||||
session.sourceProcess,
|
||||
session.proxyUrl ?? null,
|
||||
);
|
||||
runSharedDebugProxyCaptureWrite(this, () =>
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO capture_sessions (
|
||||
id, started_at, ended_at, mode, source_scope, source_process, proxy_url
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
started_at=MIN(capture_sessions.started_at, excluded.started_at),
|
||||
ended_at=excluded.ended_at,
|
||||
mode=CASE
|
||||
WHEN capture_sessions.mode = 'implicit' THEN excluded.mode
|
||||
ELSE capture_sessions.mode
|
||||
END,
|
||||
proxy_url=excluded.proxy_url,
|
||||
source_process=excluded.source_process`,
|
||||
)
|
||||
.run(
|
||||
session.id,
|
||||
session.startedAt,
|
||||
session.endedAt ?? null,
|
||||
session.mode,
|
||||
session.sourceScope,
|
||||
session.sourceProcess,
|
||||
session.proxyUrl ?? null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
endSession(sessionId: string, endedAt = Date.now()): void {
|
||||
this.db
|
||||
.prepare(`UPDATE capture_sessions SET ended_at = ? WHERE id = ?`)
|
||||
.run(endedAt, sessionId);
|
||||
const update = () =>
|
||||
this.db
|
||||
.prepare(`UPDATE capture_sessions SET ended_at = ? WHERE id = ?`)
|
||||
.run(endedAt, sessionId);
|
||||
if (this.pathBased) {
|
||||
update();
|
||||
return;
|
||||
}
|
||||
runSharedDebugProxyCaptureWrite(this, update);
|
||||
}
|
||||
|
||||
persistPayload(data: Buffer, contentType?: string): CaptureBlobRecord | SharedCaptureBlobRecord {
|
||||
@@ -325,21 +356,23 @@ class DebugProxyCaptureStoreImpl {
|
||||
...(contentType ? { contentType } : {}),
|
||||
};
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO capture_blobs (
|
||||
blob_id, content_type, encoding, size_bytes, sha256, data, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
blobId,
|
||||
contentType ?? null,
|
||||
"gzip",
|
||||
data.byteLength,
|
||||
sha256,
|
||||
gzipSync(data),
|
||||
Date.now(),
|
||||
);
|
||||
runSharedDebugProxyCaptureWrite(this, () =>
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO capture_blobs (
|
||||
blob_id, content_type, encoding, size_bytes, sha256, data, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
blobId,
|
||||
contentType ?? null,
|
||||
"gzip",
|
||||
data.byteLength,
|
||||
sha256,
|
||||
gzipSync(data),
|
||||
Date.now(),
|
||||
),
|
||||
);
|
||||
return {
|
||||
blobId,
|
||||
encoding: "gzip",
|
||||
@@ -354,7 +387,7 @@ class DebugProxyCaptureStoreImpl {
|
||||
this.insertEvent(event, event.dataBlobId ?? null);
|
||||
return;
|
||||
}
|
||||
runSqliteImmediateTransactionSync(this.db, () => {
|
||||
runSharedDebugProxyCaptureWrite(this, () => {
|
||||
// Capture can be invoked directly by provider seams before the top-level
|
||||
// runtime initializes. Keep the shared-schema foreign key valid without
|
||||
// making diagnostics break the request they are observing.
|
||||
@@ -628,7 +661,7 @@ class DebugProxyCaptureStoreImpl {
|
||||
}
|
||||
return { sessions: sessionCount, events: eventCount, blobs };
|
||||
}
|
||||
return runSqliteImmediateTransactionSync(this.db, () => {
|
||||
return runSharedDebugProxyCaptureWrite(this, () => {
|
||||
const sessionCount =
|
||||
(
|
||||
this.db.prepare(`SELECT COUNT(*) AS count FROM capture_sessions`).get() as {
|
||||
@@ -656,7 +689,7 @@ class DebugProxyCaptureStoreImpl {
|
||||
if (this.pathBased) {
|
||||
return this.deletePathBasedSessions(uniqueSessionIds);
|
||||
}
|
||||
return runSqliteImmediateTransactionSync(this.db, () => {
|
||||
return runSharedDebugProxyCaptureWrite(this, () => {
|
||||
const placeholders = uniqueSessionIds.map(() => "?").join(", ");
|
||||
const blobRows = this.db
|
||||
.prepare(
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
} from "./openclaw-agent-db.js";
|
||||
import { OPENCLAW_AGENT_SCHEMA_SQL } from "./openclaw-agent-schema.js";
|
||||
import { CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS } from "./openclaw-state-db-additive-columns.js";
|
||||
import { CLAW_LAZY_ADDITIVE_STATE_COLUMNS } from "./openclaw-state-db-maintenance.js";
|
||||
import { ensureAdditiveStateColumns } from "./openclaw-state-db-schema-additive.js";
|
||||
import {
|
||||
assertOpenClawStateDatabaseForMaintenance,
|
||||
OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
} from "./openclaw-state-db.js";
|
||||
import { OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY } from "./openclaw-state-schema-compatibility.js";
|
||||
import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js";
|
||||
|
||||
describe("OpenClaw database maintenance schema validation", () => {
|
||||
@@ -129,7 +129,7 @@ describe("OpenClaw database maintenance schema validation", () => {
|
||||
CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.map(
|
||||
({ columnName, tableName }) => `${tableName}.${columnName}`,
|
||||
),
|
||||
).toEqual(CLAW_LAZY_ADDITIVE_STATE_COLUMNS);
|
||||
).toEqual(OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY.allowedMissingColumns);
|
||||
expect(
|
||||
CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.map(
|
||||
({ columnName, dataType, tableName }) => `${tableName}.${columnName} ${dataType}`,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { requireNodeSqlite } from "../infra/node-sqlite.js";
|
||||
import { collectSqliteSchemaIssues } from "../infra/sqlite-schema-contract.js";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
OPENCLAW_AGENT_SCHEMA_VERSION,
|
||||
@@ -9,6 +12,7 @@ import {
|
||||
} from "./openclaw-agent-db.js";
|
||||
import {
|
||||
assertOpenClawDatabasesReadyForRestart,
|
||||
preflightOpenClawStateDatabasePath,
|
||||
preflightOpenClawDatabaseSchemas,
|
||||
} from "./openclaw-database-preflight.js";
|
||||
import {
|
||||
@@ -16,6 +20,7 @@ import {
|
||||
OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
openOpenClawStateDatabase,
|
||||
} from "./openclaw-state-db.js";
|
||||
import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
@@ -25,6 +30,283 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("OpenClaw database schema preflight", () => {
|
||||
function snapshotSourceFamily(databasePath: string) {
|
||||
const paths = [databasePath, `${databasePath}-wal`, `${databasePath}-shm`].filter(
|
||||
fs.existsSync,
|
||||
);
|
||||
return {
|
||||
entries: fs.readdirSync(path.dirname(databasePath)).toSorted(),
|
||||
files: paths.map((pathname) => {
|
||||
const stat = fs.statSync(pathname, { bigint: true });
|
||||
return {
|
||||
pathname,
|
||||
bytes: fs.readFileSync(pathname),
|
||||
birthtimeNs: stat.birthtimeNs,
|
||||
ctimeNs: stat.ctimeNs,
|
||||
dev: stat.dev,
|
||||
ino: stat.ino,
|
||||
mtimeNs: stat.mtimeNs,
|
||||
size: stat.size,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createExplicitStateDatabase(schemaSql = OPENCLAW_STATE_SCHEMA_SQL): string {
|
||||
const stateDir = tempDirs.make("openclaw-explicit-state-preflight-");
|
||||
const databasePath = path.join(stateDir, "candidate.sqlite");
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const database = new DatabaseSync(databasePath);
|
||||
try {
|
||||
database.exec(`${schemaSql}; PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION};`);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO schema_meta (
|
||||
meta_key, role, schema_version, agent_id, app_version, created_at, updated_at
|
||||
) VALUES ('primary', 'global', ?, NULL, NULL, 1, 1)`,
|
||||
)
|
||||
.run(OPENCLAW_STATE_SCHEMA_VERSION);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
return databasePath;
|
||||
}
|
||||
|
||||
it("reports an exact current schema for one explicit copied database", async () => {
|
||||
const stateDir = tempDirs.make("openclaw-runtime-state-preflight-");
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
const opened = openOpenClawStateDatabase({ env });
|
||||
const databasePath = opened.path;
|
||||
expect(
|
||||
opened.db
|
||||
.prepare(
|
||||
"SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'execution_identity_contexts'",
|
||||
)
|
||||
.get(),
|
||||
).toBeUndefined();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
|
||||
await expect(preflightOpenClawStateDatabasePath(databasePath)).resolves.toEqual({
|
||||
schema: "openclaw.state-schema-preflight.v1",
|
||||
databasePath,
|
||||
targetVersion: OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
foundVersion: OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
ownership: null,
|
||||
issues: [],
|
||||
status: "exact",
|
||||
requiresWrite: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a supported persistent column definition as exact", async () => {
|
||||
const databasePath = createExplicitStateDatabase(
|
||||
OPENCLAW_STATE_SCHEMA_SQL.replace(
|
||||
" kind TEXT NOT NULL,\n sensitivity TEXT NOT NULL,",
|
||||
" kind TEXT NOT NULL DEFAULT 'followup',\n sensitivity TEXT NOT NULL,",
|
||||
),
|
||||
);
|
||||
|
||||
await expect(preflightOpenClawStateDatabasePath(databasePath)).resolves.toEqual({
|
||||
schema: "openclaw.state-schema-preflight.v1",
|
||||
databasePath,
|
||||
targetVersion: OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
foundVersion: OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
ownership: null,
|
||||
status: "exact",
|
||||
requiresWrite: false,
|
||||
issues: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a copied current schema with a future bare nullable column without touching it", async () => {
|
||||
const sourcePath = createExplicitStateDatabase();
|
||||
const databasePath = path.join(
|
||||
tempDirs.make("openclaw-copied-state-preflight-"),
|
||||
"candidate.sqlite",
|
||||
);
|
||||
fs.copyFileSync(sourcePath, databasePath);
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const database = new DatabaseSync(databasePath);
|
||||
try {
|
||||
database.exec("ALTER TABLE worktrees ADD COLUMN future_note TEXT;");
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
const before = snapshotSourceFamily(databasePath);
|
||||
|
||||
await expect(preflightOpenClawStateDatabasePath(databasePath)).resolves.toEqual({
|
||||
schema: "openclaw.state-schema-preflight.v1",
|
||||
databasePath,
|
||||
targetVersion: OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
foundVersion: OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
ownership: null,
|
||||
status: "exact",
|
||||
requiresWrite: false,
|
||||
issues: [],
|
||||
});
|
||||
expect(snapshotSourceFamily(databasePath)).toEqual(before);
|
||||
});
|
||||
|
||||
it("classifies a drifted canonical named index as startup-repairable", async () => {
|
||||
const databasePath = createExplicitStateDatabase();
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const database = new DatabaseSync(databasePath);
|
||||
try {
|
||||
database.exec(`
|
||||
DROP INDEX idx_task_runs_status;
|
||||
CREATE INDEX idx_task_runs_status ON task_runs(task_id);
|
||||
`);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
await expect(preflightOpenClawStateDatabasePath(databasePath)).resolves.toEqual({
|
||||
schema: "openclaw.state-schema-preflight.v1",
|
||||
databasePath,
|
||||
targetVersion: OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
foundVersion: OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
ownership: null,
|
||||
status: "startup-repairable",
|
||||
requiresWrite: true,
|
||||
issues: [
|
||||
{
|
||||
code: "missing-or-drifted-index",
|
||||
message: "missing or drifted index idx_task_runs_status",
|
||||
objectName: "idx_task_runs_status",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies the same-version run-end cleanup column as startup-repairable without touching the source", async () => {
|
||||
const sourcePath = createExplicitStateDatabase(
|
||||
OPENCLAW_STATE_SCHEMA_SQL.replace(
|
||||
" removed_at INTEGER,\n run_end_cleanup_json TEXT\n",
|
||||
" removed_at INTEGER\n",
|
||||
),
|
||||
);
|
||||
const snapshotPath = path.join(
|
||||
tempDirs.make("openclaw-consolidated-state-preflight-"),
|
||||
"candidate.sqlite",
|
||||
);
|
||||
const sqlite = requireNodeSqlite();
|
||||
const writer = new sqlite.DatabaseSync(sourcePath);
|
||||
try {
|
||||
writer.exec("PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0;");
|
||||
writer
|
||||
.prepare(
|
||||
"INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES ('preflight.probe', '{}', 1)",
|
||||
)
|
||||
.run();
|
||||
await sqlite.backup(writer, snapshotPath);
|
||||
writer
|
||||
.prepare(
|
||||
"INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES ('preflight.after-backup', '{}', 2)",
|
||||
)
|
||||
.run();
|
||||
expect(fs.existsSync(`${sourcePath}-wal`)).toBe(true);
|
||||
expect(fs.existsSync(`${sourcePath}-shm`)).toBe(true);
|
||||
for (const suffix of ["-wal", "-shm", "-journal"]) {
|
||||
expect(fs.existsSync(`${snapshotPath}${suffix}`)).toBe(false);
|
||||
}
|
||||
const before = snapshotSourceFamily(sourcePath);
|
||||
|
||||
const result = await preflightOpenClawStateDatabasePath(snapshotPath);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
foundVersion: OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
status: "startup-repairable",
|
||||
requiresWrite: true,
|
||||
issues: [
|
||||
{
|
||||
code: "missing-column",
|
||||
objectName: "worktrees.run_end_cleanup_json",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(snapshotSourceFamily(sourcePath)).toEqual(before);
|
||||
} finally {
|
||||
writer.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an explicit preflight path with sidecars without touching it", async () => {
|
||||
const databasePath = createExplicitStateDatabase();
|
||||
const sqlite = requireNodeSqlite();
|
||||
const writer = new sqlite.DatabaseSync(databasePath);
|
||||
try {
|
||||
writer.exec("PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0;");
|
||||
writer
|
||||
.prepare(
|
||||
"INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES ('preflight.live', '{}', 1)",
|
||||
)
|
||||
.run();
|
||||
const before = snapshotSourceFamily(databasePath);
|
||||
|
||||
await expect(preflightOpenClawStateDatabasePath(databasePath)).resolves.toMatchObject({
|
||||
foundVersion: null,
|
||||
status: "indeterminate",
|
||||
requiresWrite: false,
|
||||
reason: expect.stringMatching(/consolidated snapshot.*sidecars.*online backup/iu),
|
||||
});
|
||||
expect(snapshotSourceFamily(databasePath)).toEqual(before);
|
||||
} finally {
|
||||
writer.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports an explicit unreadable path as indeterminate", async () => {
|
||||
const stateDir = tempDirs.make("openclaw-explicit-unreadable-preflight-");
|
||||
const databasePath = path.join(stateDir, "not-sqlite.db");
|
||||
fs.writeFileSync(databasePath, "not a sqlite database");
|
||||
|
||||
await expect(preflightOpenClawStateDatabasePath(databasePath)).resolves.toMatchObject({
|
||||
databasePath,
|
||||
foundVersion: null,
|
||||
status: "indeterminate",
|
||||
requiresWrite: false,
|
||||
reason: expect.stringMatching(/database|file/iu),
|
||||
});
|
||||
});
|
||||
|
||||
it("reports invalid negative schema metadata as indeterminate", async () => {
|
||||
const databasePath = createExplicitStateDatabase();
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const database = new DatabaseSync(databasePath);
|
||||
try {
|
||||
database.exec("PRAGMA user_version = -1;");
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
await expect(preflightOpenClawStateDatabasePath(databasePath)).resolves.toMatchObject({
|
||||
foundVersion: -1,
|
||||
status: "indeterminate",
|
||||
reason: expect.stringContaining("invalid schema version metadata"),
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a current-v6 additive column as incompatible with the older v6 shape", () => {
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const database = new DatabaseSync(":memory:");
|
||||
try {
|
||||
database.exec(OPENCLAW_STATE_SCHEMA_SQL);
|
||||
const olderV6Schema = OPENCLAW_STATE_SCHEMA_SQL.replace(
|
||||
" removed_at INTEGER,\n run_end_cleanup_json TEXT\n",
|
||||
" removed_at INTEGER\n",
|
||||
);
|
||||
|
||||
expect(collectSqliteSchemaIssues(database, olderV6Schema)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "unexpected-column",
|
||||
objectName: "worktrees.run_end_cleanup_json",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps package schema support metadata aligned", () => {
|
||||
expect(packageJson.openclaw.schemaVersions).toEqual({
|
||||
state: OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
@@ -268,4 +550,3 @@ describe("OpenClaw database schema preflight", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
import fs from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
executeSqliteQuerySync,
|
||||
getNodeSqliteKysely,
|
||||
} from "../infra/kysely-sync.js";
|
||||
import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
|
||||
import { openNodeSqliteDatabase, resolveImmutableSqliteFileUri } from "../infra/node-sqlite.js";
|
||||
import { assertSqliteIntegrity } from "../infra/sqlite-integrity.js";
|
||||
import {
|
||||
collectSqliteSchemaIssues,
|
||||
type SqliteSchemaIssue,
|
||||
} from "../infra/sqlite-schema-contract.js";
|
||||
import {
|
||||
describeRunningOpenClawBuild,
|
||||
readSqliteUserVersion,
|
||||
@@ -20,9 +25,23 @@ import {
|
||||
OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
|
||||
OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
} from "./openclaw-state-db-contract.js";
|
||||
import { assertOpenClawStateDatabaseForMaintenance } from "./openclaw-state-db-maintenance.js";
|
||||
import {
|
||||
assertOpenClawStateDatabaseOwner,
|
||||
assertOpenClawStateDatabaseForMaintenance,
|
||||
} from "./openclaw-state-db-maintenance.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "./openclaw-state-db.paths.js";
|
||||
import {
|
||||
inspectOpenClawStateOwnershipFromDatabase,
|
||||
type OpenClawExternalStateOwnership,
|
||||
} from "./openclaw-state-ownership.js";
|
||||
import {
|
||||
getOpenClawStateRuntimeSchema,
|
||||
isOpenClawStateStartupRepairableSchemaIssue,
|
||||
OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY,
|
||||
STATE_PERSISTENT_SCHEMA_COMPATIBILITY,
|
||||
} from "./openclaw-state-schema-compatibility.js";
|
||||
import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js";
|
||||
|
||||
export { OPENCLAW_DATABASE_SCHEMA_DOCS_URL } from "./openclaw-state-db.js";
|
||||
|
||||
@@ -46,6 +65,18 @@ export type OpenClawDatabaseSchemaPreflight = {
|
||||
indeterminate: IndeterminateOpenClawDatabase[];
|
||||
};
|
||||
|
||||
type OpenClawStateSchemaPreflightResult = {
|
||||
databasePath: string;
|
||||
foundVersion: number | null;
|
||||
issues: SqliteSchemaIssue[];
|
||||
ownership: OpenClawExternalStateOwnership | null;
|
||||
reason?: string;
|
||||
requiresWrite: boolean;
|
||||
schema: "openclaw.state-schema-preflight.v1";
|
||||
status: "exact" | "startup-repairable" | "migration-required" | "incompatible" | "indeterminate";
|
||||
targetVersion: number;
|
||||
};
|
||||
|
||||
type AgentRegistryDatabase = Pick<OpenClawStateKyselyDatabase, "agent_databases">;
|
||||
|
||||
type OpenClawDatabaseSchemaPreflightOperation = "doctor" | "gateway-restart" | "gateway-startup";
|
||||
@@ -142,6 +173,122 @@ function readRegisteredAgentDatabases(database: DatabaseSync): Array<{
|
||||
);
|
||||
}
|
||||
|
||||
function deduplicateSchemaIssues(issues: readonly SqliteSchemaIssue[]): SqliteSchemaIssue[] {
|
||||
return [
|
||||
...new Map(
|
||||
issues.map((issue) => [`${issue.code}\0${issue.objectName}`, issue] as const),
|
||||
).values(),
|
||||
];
|
||||
}
|
||||
|
||||
/** Compare one explicit SQLite file with this release's canonical shared-state schema. */
|
||||
export async function preflightOpenClawStateDatabasePath(
|
||||
databasePath: string,
|
||||
): Promise<OpenClawStateSchemaPreflightResult> {
|
||||
const resolvedPath = path.resolve(databasePath);
|
||||
const base = {
|
||||
schema: "openclaw.state-schema-preflight.v1",
|
||||
databasePath: resolvedPath,
|
||||
targetVersion: OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
} as const;
|
||||
let database: DatabaseSync | undefined;
|
||||
let foundVersion: number | null = null;
|
||||
let ownership: OpenClawExternalStateOwnership | null = null;
|
||||
const result = (
|
||||
status: OpenClawStateSchemaPreflightResult["status"],
|
||||
details: { issues?: SqliteSchemaIssue[]; reason?: string; requiresWrite?: boolean } = {},
|
||||
): OpenClawStateSchemaPreflightResult => ({
|
||||
...base,
|
||||
foundVersion,
|
||||
ownership,
|
||||
issues: details.issues ?? [],
|
||||
status,
|
||||
requiresWrite: details.requiresWrite ?? false,
|
||||
...(details.reason ? { reason: details.reason } : {}),
|
||||
});
|
||||
try {
|
||||
const inspectionPath = realpathSync.native(resolvedPath);
|
||||
const sidecars = ["-wal", "-shm", "-journal"].filter((suffix) =>
|
||||
existsSync(`${inspectionPath}${suffix}`),
|
||||
);
|
||||
if (sidecars.length > 0) {
|
||||
throw new Error(
|
||||
`SQLite preflight requires a consolidated snapshot with no sidecars; found ${sidecars.join(", ")}. Create a WAL-aware online backup and preflight the resulting standalone file.`,
|
||||
);
|
||||
}
|
||||
database = openNodeSqliteDatabase(resolveImmutableSqliteFileUri(inspectionPath), {
|
||||
readOnly: true,
|
||||
});
|
||||
database.exec(
|
||||
`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS}; PRAGMA query_only = ON; PRAGMA trusted_schema = OFF;`,
|
||||
);
|
||||
assertSqliteIntegrity(database, resolvedPath);
|
||||
foundVersion = readSqliteUserVersion(database);
|
||||
if (!Number.isSafeInteger(foundVersion) || foundVersion < 0) {
|
||||
throw new Error(
|
||||
`OpenClaw state database ${resolvedPath} has invalid schema version metadata.`,
|
||||
);
|
||||
}
|
||||
if (foundVersion > OPENCLAW_STATE_SCHEMA_VERSION) {
|
||||
try {
|
||||
ownership = inspectOpenClawStateOwnershipFromDatabase(database, resolvedPath);
|
||||
} catch {
|
||||
// A newer release can own a newer metadata contract; the numeric refusal remains decisive.
|
||||
}
|
||||
return result("incompatible");
|
||||
}
|
||||
ownership = inspectOpenClawStateOwnershipFromDatabase(database, resolvedPath);
|
||||
if (foundVersion < OPENCLAW_STATE_SCHEMA_VERSION) {
|
||||
return result("migration-required", { requiresWrite: true });
|
||||
}
|
||||
assertOpenClawStateDatabaseOwner(database, { pathname: resolvedPath });
|
||||
const metadata = database
|
||||
.prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary' LIMIT 1")
|
||||
.get() as { schema_version?: unknown } | undefined;
|
||||
if (metadata?.schema_version !== foundVersion) {
|
||||
throw new Error(
|
||||
`OpenClaw state database ${resolvedPath} metadata schema version ${typeof metadata?.schema_version === "number" ? metadata.schema_version : "invalid"} does not match ${foundVersion}.`,
|
||||
);
|
||||
}
|
||||
const maintenanceIssues = collectSqliteSchemaIssues(
|
||||
database,
|
||||
OPENCLAW_STATE_SCHEMA_SQL,
|
||||
OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY,
|
||||
);
|
||||
const blockingIssues = maintenanceIssues.filter(
|
||||
(issue) => !isOpenClawStateStartupRepairableSchemaIssue(issue),
|
||||
);
|
||||
if (blockingIssues.length > 0) {
|
||||
return result("incompatible", { issues: deduplicateSchemaIssues(blockingIssues) });
|
||||
}
|
||||
const projectedRuntimeIssues = collectSqliteSchemaIssues(
|
||||
database,
|
||||
getOpenClawStateRuntimeSchema({ includeVersionLazyAdditiveTables: false }),
|
||||
STATE_PERSISTENT_SCHEMA_COMPATIBILITY,
|
||||
);
|
||||
const projectedRuntimeBlockingIssues = projectedRuntimeIssues.filter(
|
||||
(issue) => !isOpenClawStateStartupRepairableSchemaIssue(issue),
|
||||
);
|
||||
if (projectedRuntimeBlockingIssues.length > 0) {
|
||||
return result("incompatible", {
|
||||
issues: deduplicateSchemaIssues(projectedRuntimeBlockingIssues),
|
||||
});
|
||||
}
|
||||
const startupRepairableIssues = deduplicateSchemaIssues([
|
||||
...maintenanceIssues,
|
||||
...projectedRuntimeIssues,
|
||||
]);
|
||||
return result(startupRepairableIssues.length > 0 ? "startup-repairable" : "exact", {
|
||||
issues: startupRepairableIssues,
|
||||
requiresWrite: startupRepairableIssues.length > 0,
|
||||
});
|
||||
} catch (error) {
|
||||
return result("indeterminate", { reason: formatErrorMessage(error) });
|
||||
} finally {
|
||||
database?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read schema headers and optionally verify current schema shape without repairing it. */
|
||||
export function preflightOpenClawDatabaseSchemas(options: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
assertSqliteSchemaContains,
|
||||
assertSqliteSchemaTablesPresent,
|
||||
type SqliteSchemaCompatibility,
|
||||
} from "../infra/sqlite-schema-contract.js";
|
||||
import {
|
||||
createNewerSqliteSchemaVersionError,
|
||||
@@ -16,66 +15,9 @@ import {
|
||||
type OpenClawStateDatabaseOptions,
|
||||
} from "./openclaw-state-db-contract.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "./openclaw-state-db.paths.js";
|
||||
import { OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY } from "./openclaw-state-schema-compatibility.js";
|
||||
import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js";
|
||||
|
||||
/**
|
||||
* Additive Claw provenance and worker-environment columns that only a writable
|
||||
* open can ensure. A same-version database written before them stays readable
|
||||
* so read-only planning surfaces are not refused before they can report anything.
|
||||
*/
|
||||
export const CLAW_LAZY_ADDITIVE_STATE_COLUMNS = [
|
||||
"claw_installs.bootstrap_content_digest",
|
||||
"claw_installs.bootstrap_source_path",
|
||||
"worker_environments.desktop_json",
|
||||
"claw_package_refs.extension_adapter_identity",
|
||||
"claw_package_refs.extension_detected_format",
|
||||
"claw_package_refs.extension_format",
|
||||
"claw_package_refs.extension_id",
|
||||
"claw_package_refs.extension_mapped_json",
|
||||
"claw_package_refs.extension_unavailable_json",
|
||||
"worker_environments.shared_host",
|
||||
"worktrees.run_end_cleanup_json",
|
||||
] as const;
|
||||
|
||||
const OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY = {
|
||||
allowCompatibleAdditiveColumns: true,
|
||||
allowedMissingTables: LAZY_ADDITIVE_STATE_TABLES,
|
||||
allowedMissingColumns: CLAW_LAZY_ADDITIVE_STATE_COLUMNS,
|
||||
allowedColumnDefinitions: {
|
||||
"diagnostic_events.sequence": ["sequence INTEGER NOT NULL DEFAULT 0"],
|
||||
"commitments.attempts": ["attempts INTEGER NOT NULL DEFAULT 0"],
|
||||
"commitments.confidence": ["confidence REAL NOT NULL DEFAULT 0"],
|
||||
"commitments.created_at_ms": ["created_at_ms INTEGER NOT NULL DEFAULT 0"],
|
||||
"commitments.dedupe_key": ["dedupe_key TEXT NOT NULL DEFAULT ''"],
|
||||
"commitments.due_timezone": ["due_timezone TEXT NOT NULL DEFAULT 'UTC'"],
|
||||
"commitments.kind": ["kind TEXT NOT NULL DEFAULT 'followup'"],
|
||||
"commitments.reason": ["reason TEXT NOT NULL DEFAULT ''"],
|
||||
"commitments.sensitivity": ["sensitivity TEXT NOT NULL DEFAULT 'normal'"],
|
||||
"commitments.source": ["source TEXT NOT NULL DEFAULT 'unknown'"],
|
||||
"commitments.suggested_text": ["suggested_text TEXT NOT NULL DEFAULT ''"],
|
||||
"claw_package_refs.package_integrity": [
|
||||
"package_integrity TEXT NOT NULL DEFAULT 'sha256:0000000000000000000000000000000000000000000000000000000000000000'",
|
||||
],
|
||||
"claw_package_refs.updated_at_ms": ["updated_at_ms INTEGER NOT NULL DEFAULT 0"],
|
||||
"cron_jobs.created_at_ms": ["created_at_ms INTEGER NOT NULL DEFAULT 0"],
|
||||
"cron_jobs.enabled": ["enabled INTEGER NOT NULL DEFAULT 1"],
|
||||
"cron_jobs.name": ["name TEXT NOT NULL DEFAULT ''"],
|
||||
"cron_jobs.payload_kind": ["payload_kind TEXT NOT NULL DEFAULT 'message'"],
|
||||
"cron_jobs.schedule_kind": ["schedule_kind TEXT NOT NULL DEFAULT 'manual'"],
|
||||
"cron_jobs.session_target": ["session_target TEXT NOT NULL DEFAULT 'main'"],
|
||||
"cron_jobs.wake_mode": ["wake_mode TEXT NOT NULL DEFAULT 'auto'"],
|
||||
"current_conversation_bindings.conversation_kind": [
|
||||
"conversation_kind TEXT NOT NULL DEFAULT 'channel'",
|
||||
],
|
||||
"current_conversation_bindings.target_agent_id": [
|
||||
"target_agent_id TEXT NOT NULL DEFAULT 'main'",
|
||||
],
|
||||
"operator_approvals.resolution_ref": ["resolution_ref TEXT"],
|
||||
"worker_environments.desktop_json": ["desktop_json TEXT"],
|
||||
"worker_environments.shared_host": ["shared_host INTEGER CHECK (shared_host IN (0, 1))"],
|
||||
},
|
||||
} satisfies SqliteSchemaCompatibility;
|
||||
|
||||
const STATE_V5_ADDITIVE_TABLES = [
|
||||
"agent_database_leases",
|
||||
"agent_deletion_journal",
|
||||
@@ -148,7 +90,7 @@ export function assertOpenClawStateDatabaseOwner(
|
||||
/** Require the canonical shared-state owner and schema before offline file maintenance. */
|
||||
export function assertOpenClawStateDatabaseForMaintenance(
|
||||
database: DatabaseSync,
|
||||
options: { pathname: string; allowedMissingColumns?: readonly string[] },
|
||||
options: { pathname: string },
|
||||
): void {
|
||||
const userVersion = readSqliteUserVersion(database);
|
||||
if (userVersion > OPENCLAW_STATE_SCHEMA_VERSION) {
|
||||
@@ -180,12 +122,7 @@ export function assertOpenClawStateDatabaseForMaintenance(
|
||||
database,
|
||||
options.pathname,
|
||||
OPENCLAW_STATE_SCHEMA_SQL,
|
||||
options.allowedMissingColumns
|
||||
? {
|
||||
...OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY,
|
||||
allowedMissingColumns: options.allowedMissingColumns,
|
||||
}
|
||||
: OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY,
|
||||
OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,17 @@ import {
|
||||
} from "./openclaw-state-db-maintenance.js";
|
||||
import { ensureOpenClawStatePermissions } from "./openclaw-state-db-permissions.js";
|
||||
import { ensureColumn } from "./openclaw-state-db-schema-helpers.js";
|
||||
import { assertOpenClawStateWriteAllowed } from "./openclaw-state-ownership.js";
|
||||
|
||||
function ensureStartupMigrationCheckpointSchema(db: DatabaseSync, pathname: string): void {
|
||||
function ensureStartupMigrationCheckpointSchema(
|
||||
db: DatabaseSync,
|
||||
pathname: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): void {
|
||||
runSqliteImmediateTransactionSync(
|
||||
db,
|
||||
() => {
|
||||
assertOpenClawStateWriteAllowed({ database: db, databasePath: pathname, env });
|
||||
assertSupportedSchemaVersion(db, pathname);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_meta (
|
||||
@@ -62,6 +68,7 @@ export function withOpenClawStateStartupMigrationCheckpointDatabase<T>(
|
||||
): T {
|
||||
const env = options.env ?? process.env;
|
||||
const pathname = resolveDatabasePath(options);
|
||||
assertOpenClawStateWriteAllowed({ databasePath: pathname, env });
|
||||
ensureOpenClawStatePermissions(pathname, env);
|
||||
const db = openNodeSqliteDatabase(pathname);
|
||||
try {
|
||||
@@ -69,7 +76,7 @@ export function withOpenClawStateStartupMigrationCheckpointDatabase<T>(
|
||||
busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
|
||||
});
|
||||
assertSqliteIntegrity(db, pathname);
|
||||
ensureStartupMigrationCheckpointSchema(db, pathname);
|
||||
ensureStartupMigrationCheckpointSchema(db, pathname, env);
|
||||
return callback(db);
|
||||
} finally {
|
||||
db.close();
|
||||
|
||||
@@ -45,10 +45,7 @@ import {
|
||||
} from "./openclaw-quarantine-store.js";
|
||||
import { repairAuditEventsSchema } from "./openclaw-state-db-audit-migration.js";
|
||||
import {
|
||||
FIRST_USE_STATE_INDEXES,
|
||||
FIRST_USE_STATE_TABLES,
|
||||
OPENCLAW_DATABASE_SCHEMA_DOCS_URL,
|
||||
LAZY_ADDITIVE_STATE_INDEXES,
|
||||
LAZY_ADDITIVE_STATE_TABLES,
|
||||
OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
|
||||
OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
@@ -60,7 +57,6 @@ import {
|
||||
assertOpenClawStateDatabaseForMaintenance,
|
||||
assertOpenClawStateDatabaseV5ForMigration,
|
||||
assertSupportedSchemaVersion,
|
||||
CLAW_LAZY_ADDITIVE_STATE_COLUMNS,
|
||||
createOpenClawDatabaseVerificationError,
|
||||
resolveDatabasePath,
|
||||
} from "./openclaw-state-db-maintenance.js";
|
||||
@@ -78,6 +74,11 @@ import {
|
||||
} from "./openclaw-state-db-schema-repair.js";
|
||||
import * as sessionWatchMigration from "./openclaw-state-db-session-watch-migration.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js";
|
||||
import {
|
||||
assertOpenClawStateWriteAllowed,
|
||||
OpenClawStateOwnershipError,
|
||||
} from "./openclaw-state-ownership.js";
|
||||
import { getOpenClawStateRuntimeSchema } from "./openclaw-state-schema-compatibility.js";
|
||||
import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js";
|
||||
|
||||
export {
|
||||
@@ -85,7 +86,6 @@ export {
|
||||
OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
|
||||
OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
};
|
||||
export const STATE_READ_ONLY_COMPATIBLE_MISSING_COLUMNS = CLAW_LAZY_ADDITIVE_STATE_COLUMNS;
|
||||
export type {
|
||||
OpenClawStateDatabase,
|
||||
OpenClawStateDatabaseOptions,
|
||||
@@ -201,45 +201,11 @@ export function assertOpenClawStateDatabaseFreshOpenAllowed(
|
||||
type OpenClawStateMetadataDatabase = Pick<OpenClawStateKyselyDatabase, "schema_meta">;
|
||||
const stateDbLog = createSubsystemLogger("state/db");
|
||||
|
||||
function canonicalStateSchemaForRuntime(options: {
|
||||
includeVersionLazyAdditiveTables: boolean;
|
||||
}): string {
|
||||
// Current-version databases may lack lazy additive tables. First-use tables
|
||||
// remain absent on every schema path so only their feature owner can create them.
|
||||
let eagerSchema = OPENCLAW_STATE_SCHEMA_SQL;
|
||||
const omittedTables = options.includeVersionLazyAdditiveTables
|
||||
? FIRST_USE_STATE_TABLES
|
||||
: LAZY_ADDITIVE_STATE_TABLES;
|
||||
const omittedIndexes = options.includeVersionLazyAdditiveTables
|
||||
? FIRST_USE_STATE_INDEXES
|
||||
: LAZY_ADDITIVE_STATE_INDEXES;
|
||||
for (const tableName of omittedTables) {
|
||||
const startMarker = `CREATE TABLE IF NOT EXISTS ${tableName} (`;
|
||||
const start = eagerSchema.indexOf(startMarker);
|
||||
const endMarker = "\n) STRICT;";
|
||||
const end = start >= 0 ? eagerSchema.indexOf(endMarker, start) : -1;
|
||||
if (start < 0 || end < 0) {
|
||||
throw new Error(`lazy additive state schema block is missing for ${tableName}`);
|
||||
}
|
||||
eagerSchema = `${eagerSchema.slice(0, start)}${eagerSchema.slice(end + endMarker.length)}`;
|
||||
}
|
||||
for (const indexName of omittedIndexes) {
|
||||
const startMarker = `CREATE INDEX IF NOT EXISTS ${indexName}`;
|
||||
const start = eagerSchema.indexOf(startMarker);
|
||||
const end = start >= 0 ? eagerSchema.indexOf(";", start) : -1;
|
||||
if (start < 0 || end < 0) {
|
||||
throw new Error(`lazy additive state schema index is missing for ${indexName}`);
|
||||
}
|
||||
eagerSchema = `${eagerSchema.slice(0, start)}${eagerSchema.slice(end + 1)}`;
|
||||
}
|
||||
return eagerSchema;
|
||||
}
|
||||
|
||||
function executeCanonicalStateSchema(
|
||||
database: DatabaseSync,
|
||||
options: { includeVersionLazyAdditiveTables: boolean },
|
||||
): void {
|
||||
database.exec(canonicalStateSchemaForRuntime(options));
|
||||
database.exec(getOpenClawStateRuntimeSchema(options));
|
||||
}
|
||||
|
||||
export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabaseOptions = {}): {
|
||||
@@ -251,9 +217,11 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase
|
||||
if (!existsSync(pathname)) {
|
||||
return { changes: [], warnings: [] };
|
||||
}
|
||||
assertOpenClawStateWriteAllowed({ databasePath: pathname, env });
|
||||
ensureOpenClawStatePermissions(pathname, env);
|
||||
const db = openNodeSqliteDatabase(pathname);
|
||||
const rebuiltIndexNames = new Set<string>();
|
||||
let ownershipRefused = false;
|
||||
try {
|
||||
db.exec(`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS};`);
|
||||
assertSupportedSchemaVersion(db, pathname);
|
||||
@@ -261,6 +229,7 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase
|
||||
const changes = runSqliteImmediateTransactionSync(
|
||||
db,
|
||||
() => {
|
||||
assertOpenClawStateWriteAllowed({ database: db, databasePath: pathname, env });
|
||||
const applied: string[] = [];
|
||||
const previousVersion = readSqliteUserVersion(db);
|
||||
if (previousVersion === OPENCLAW_STATE_SCHEMA_VERSION) {
|
||||
@@ -307,7 +276,7 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase
|
||||
}
|
||||
const strictMigration = migrateSqliteSchemaToStrictInTransaction(
|
||||
db,
|
||||
canonicalStateSchemaForRuntime({
|
||||
getOpenClawStateRuntimeSchema({
|
||||
includeVersionLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
}),
|
||||
{ databaseLabel: pathname },
|
||||
@@ -351,6 +320,10 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof OpenClawStateOwnershipError) {
|
||||
ownershipRefused = true;
|
||||
throw err;
|
||||
}
|
||||
// Reaching this catch inside doctor means repair itself refused or failed,
|
||||
// so the runtime asserts' "run openclaw doctor --fix" advice is circular here.
|
||||
const reason = String(err).replace(
|
||||
@@ -367,7 +340,9 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase
|
||||
}
|
||||
clearNodeSqliteKyselyCacheForDatabase(db);
|
||||
db.close();
|
||||
ensureOpenClawStatePermissions(pathname, env);
|
||||
if (!ownershipRefused) {
|
||||
ensureOpenClawStatePermissions(pathname, env);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,10 +353,12 @@ export function repairOpenClawStateDatabaseSchemaIfNeeded(
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
} {
|
||||
const env = options.env ?? process.env;
|
||||
const pathname = resolveDatabasePath(options);
|
||||
if (!existsSync(pathname)) {
|
||||
return { changes: [], warnings: [] };
|
||||
}
|
||||
assertOpenClawStateWriteAllowed({ databasePath: pathname, env });
|
||||
|
||||
let needsRepair = true;
|
||||
let database: DatabaseSync | undefined;
|
||||
@@ -406,7 +383,7 @@ export function repairOpenClawStateDatabaseSchemaIfNeeded(
|
||||
return needsRepair ? repairOpenClawStateDatabaseSchema(options) : { changes: [], warnings: [] };
|
||||
}
|
||||
|
||||
function ensureSchema(db: DatabaseSync, pathname: string): void {
|
||||
function ensureSchema(db: DatabaseSync, pathname: string, env: NodeJS.ProcessEnv): void {
|
||||
const now = Date.now();
|
||||
const kysely = getNodeSqliteKysely<OpenClawStateMetadataDatabase>(db);
|
||||
// Rebuilding referenced tables requires disabling FK enforcement before BEGIN.
|
||||
@@ -415,11 +392,15 @@ function ensureSchema(db: DatabaseSync, pathname: string): void {
|
||||
runSqliteImmediateTransactionSync(
|
||||
db,
|
||||
() => {
|
||||
// Recheck ownership after BEGIN IMMEDIATE so no current-schema repair
|
||||
// can race a durable external ownership claim.
|
||||
assertOpenClawStateWriteAllowed({ database: db, databasePath: pathname, env });
|
||||
assertSupportedSchemaVersion(db, pathname);
|
||||
const previousVersion = readSqliteUserVersion(db);
|
||||
if (previousVersion === OPENCLAW_STATE_SCHEMA_VERSION) {
|
||||
repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_STATE_SCHEMA_SQL, {
|
||||
verifyPhysicalIntegrity: false,
|
||||
verifyAndRepairCanonicalSqliteIndexes(db, pathname, OPENCLAW_STATE_SCHEMA_SQL, {
|
||||
allowMissingColumns: true,
|
||||
validateAfterRepair: () => assertCurrentStateRuntimeSchema(db, pathname),
|
||||
});
|
||||
ensureAdditiveStateColumns(db);
|
||||
assertCurrentStateRuntimeSchema(db, pathname);
|
||||
@@ -438,7 +419,7 @@ function ensureSchema(db: DatabaseSync, pathname: string): void {
|
||||
repairLegacyGatewayRestartHandoffsForStrictMigration(db);
|
||||
migrateSqliteSchemaToStrictInTransaction(
|
||||
db,
|
||||
canonicalStateSchemaForRuntime({
|
||||
getOpenClawStateRuntimeSchema({
|
||||
includeVersionLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
}),
|
||||
{ databaseLabel: pathname },
|
||||
@@ -508,10 +489,7 @@ export async function openExistingOpenClawStateDatabaseReadOnly(
|
||||
assertSupportedSchemaVersion(db, pathname);
|
||||
assertSqliteIntegrity(db, pathname);
|
||||
if (readSqliteUserVersion(db) === OPENCLAW_STATE_SCHEMA_VERSION) {
|
||||
assertOpenClawStateDatabaseForMaintenance(db, {
|
||||
pathname,
|
||||
allowedMissingColumns: STATE_READ_ONLY_COMPATIBLE_MISSING_COLUMNS,
|
||||
});
|
||||
assertOpenClawStateDatabaseForMaintenance(db, { pathname });
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
@@ -550,18 +528,9 @@ export async function openExistingOpenClawStateDatabaseReadOnly(
|
||||
};
|
||||
}
|
||||
|
||||
function assertCurrentStateRuntimeSchema(
|
||||
database: DatabaseSync,
|
||||
pathname: string,
|
||||
options: { allowedMissingColumns?: readonly string[] } = {},
|
||||
): void {
|
||||
function assertCurrentStateRuntimeSchema(database: DatabaseSync, pathname: string): void {
|
||||
assertCanonicalStateSchemaShape(database, pathname);
|
||||
assertOpenClawStateDatabaseForMaintenance(database, {
|
||||
pathname,
|
||||
...(options.allowedMissingColumns
|
||||
? { allowedMissingColumns: options.allowedMissingColumns }
|
||||
: {}),
|
||||
});
|
||||
assertOpenClawStateDatabaseForMaintenance(database, { pathname });
|
||||
}
|
||||
|
||||
function assertStateDatabaseIntegrityBeforeMutation(
|
||||
@@ -583,22 +552,10 @@ function assertStateDatabaseIntegrityBeforeMutation(
|
||||
toVersion: OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
});
|
||||
}
|
||||
if (userVersion === OPENCLAW_STATE_SCHEMA_VERSION) {
|
||||
verifyAndRepairCanonicalSqliteIndexes(database, pathname, OPENCLAW_STATE_SCHEMA_SQL, {
|
||||
allowMissingColumns: true,
|
||||
validateAfterRepair: () =>
|
||||
assertCurrentStateRuntimeSchema(database, pathname, {
|
||||
allowedMissingColumns: STATE_READ_ONLY_COMPATIBLE_MISSING_COLUMNS,
|
||||
}),
|
||||
});
|
||||
ensureAdditiveStateColumns(database);
|
||||
} else {
|
||||
if (userVersion !== OPENCLAW_STATE_SCHEMA_VERSION) {
|
||||
// Every physical open proves the full file before schema mutation or exposure.
|
||||
assertSqliteIntegrity(database, pathname);
|
||||
}
|
||||
if (userVersion === OPENCLAW_STATE_SCHEMA_VERSION) {
|
||||
assertCurrentStateRuntimeSchema(database, pathname);
|
||||
}
|
||||
}
|
||||
|
||||
/** Open or return a cached shared state database after schema and migration checks. */
|
||||
@@ -606,16 +563,22 @@ function assertStateDatabaseIntegrityBeforeMutation(
|
||||
export function openOpenClawStateDatabase(
|
||||
options: OpenClawStateDatabaseOptions = {},
|
||||
): OpenClawStateDatabase {
|
||||
const env = options.env ?? process.env;
|
||||
if (options.database) {
|
||||
assertOpenClawStateWriteAllowed({
|
||||
database: options.database.db,
|
||||
databasePath: options.database.path,
|
||||
env,
|
||||
});
|
||||
return options.database;
|
||||
}
|
||||
const env = options.env ?? process.env;
|
||||
const pathname = resolveDatabasePath(options);
|
||||
// Latched paths are quarantined: the recorder closed any live handle, and
|
||||
// every open fails fast here until doctor repairs the file and clears it.
|
||||
assertOpenClawStateDatabaseOpenAllowed(options);
|
||||
const cached = cachedDatabases.get(pathname);
|
||||
if (cached?.db.isOpen) {
|
||||
assertOpenClawStateWriteAllowed({ database: cached.db, databasePath: pathname, env });
|
||||
return cached;
|
||||
}
|
||||
if (cached) {
|
||||
@@ -625,6 +588,7 @@ export function openOpenClawStateDatabase(
|
||||
cachedDatabases.delete(pathname);
|
||||
}
|
||||
assertOpenClawStateDatabaseFreshOpenAllowed(options);
|
||||
assertOpenClawStateWriteAllowed({ databasePath: pathname, env });
|
||||
ensureOpenClawStatePermissions(pathname, env);
|
||||
const db = openNodeSqliteDatabase(pathname);
|
||||
enableNodeSqliteKyselyStatementCache(db);
|
||||
@@ -644,7 +608,7 @@ export function openOpenClawStateDatabase(
|
||||
foreignKeys: true,
|
||||
synchronous: "NORMAL",
|
||||
});
|
||||
ensureSchema(db, pathname);
|
||||
ensureSchema(db, pathname, env);
|
||||
return maintenance;
|
||||
} catch (err) {
|
||||
maintenance?.close();
|
||||
@@ -680,15 +644,35 @@ export function runOpenClawStateWriteTransaction<T>(
|
||||
"busyTimeoutMs" | "operationLabel" | "slowTransactionHoldMs"
|
||||
> = {},
|
||||
): T {
|
||||
const database = openOpenClawStateDatabase(options);
|
||||
const cachedBeforeOpen = options.database ?? getOpenClawStateDatabaseIfOpen(options);
|
||||
let database: OpenClawStateDatabase;
|
||||
try {
|
||||
database = openOpenClawStateDatabase(options);
|
||||
} catch (error) {
|
||||
if (cachedBeforeOpen && isSqliteCorruptionError(error)) {
|
||||
evictCachedOpenClawStateDatabase(cachedBeforeOpen);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let result: T;
|
||||
try {
|
||||
result = runSqliteImmediateTransactionSync(database.db, () => operation(database), {
|
||||
busyTimeoutMs: transactionOptions.busyTimeoutMs ?? OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
|
||||
databaseLabel: database.path,
|
||||
...transactionOptions,
|
||||
operationLabel: transactionOptions.operationLabel ?? "state.write",
|
||||
});
|
||||
result = runSqliteImmediateTransactionSync(
|
||||
database.db,
|
||||
() => {
|
||||
assertOpenClawStateWriteAllowed({
|
||||
database: database.db,
|
||||
databasePath: database.path,
|
||||
env: options.env ?? process.env,
|
||||
});
|
||||
return operation(database);
|
||||
},
|
||||
{
|
||||
busyTimeoutMs: transactionOptions.busyTimeoutMs ?? OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
|
||||
databaseLabel: database.path,
|
||||
...transactionOptions,
|
||||
operationLabel: transactionOptions.operationLabel ?? "state.write",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (isSqliteCorruptionError(error)) {
|
||||
evictCachedOpenClawStateDatabase(database);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { isGatewayExternallySupervised } from "../infra/gateway-supervision.js";
|
||||
import {
|
||||
clearNodeSqliteKyselyCacheForDatabase,
|
||||
executeSqliteQuerySync,
|
||||
getNodeSqliteKysely,
|
||||
} from "../infra/kysely-sync.js";
|
||||
import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
|
||||
import { assertSqliteIntegrity } from "../infra/sqlite-integrity.js";
|
||||
import { runSqliteImmediateTransactionSync } from "../infra/sqlite-transaction.js";
|
||||
import { configureSqliteWalMaintenance, type SqliteWalMaintenance } from "../infra/sqlite-wal.js";
|
||||
import { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS } from "./openclaw-state-db-contract.js";
|
||||
import {
|
||||
assertOpenClawStateDatabaseForMaintenance,
|
||||
resolveDatabasePath,
|
||||
} from "./openclaw-state-db-maintenance.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
type OpenClawStateDatabaseOptions,
|
||||
} from "./openclaw-state-db.js";
|
||||
import {
|
||||
inspectOpenClawStateOwnershipFromDatabase,
|
||||
normalizeOpenClawStateManagerId,
|
||||
OpenClawStateOwnershipMetadataError,
|
||||
STATE_SUPERVISION_KEY,
|
||||
type OpenClawExternalStateOwnership,
|
||||
} from "./openclaw-state-ownership.js";
|
||||
|
||||
type OpenClawStateOwnershipOptions = Omit<OpenClawStateDatabaseOptions, "database" | "readOnly">;
|
||||
type OwnershipDatabase = Pick<OpenClawStateKyselyDatabase, "config_machine_state">;
|
||||
|
||||
class OpenClawStateOwnershipClaimConflictError extends Error {
|
||||
constructor(requestedManagerId: string, existingManagerId: string) {
|
||||
super(
|
||||
`OpenClaw shared state is already claimed by external manager ${existingManagerId}; ` +
|
||||
`manager ${requestedManagerId} cannot replace that durable ownership.`,
|
||||
);
|
||||
this.name = "OpenClawStateOwnershipClaimConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
function requireOwnershipCheckpoint(
|
||||
walMaintenance: SqliteWalMaintenance,
|
||||
databasePath: string,
|
||||
): void {
|
||||
if (!walMaintenance.checkpoint()) {
|
||||
throw new Error(
|
||||
`External ownership was committed for ${databasePath}, but its WAL checkpoint failed. Retry the same ownership claim before activating the supervisor.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function claimOwnershipRow(
|
||||
database: DatabaseSync,
|
||||
databasePath: string,
|
||||
managerId: string,
|
||||
repairMalformed: boolean,
|
||||
): OpenClawExternalStateOwnership {
|
||||
let current: OpenClawExternalStateOwnership | null = null;
|
||||
try {
|
||||
current = inspectOpenClawStateOwnershipFromDatabase(database, databasePath);
|
||||
} catch (error) {
|
||||
if (!repairMalformed || !(error instanceof OpenClawStateOwnershipMetadataError)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
if (current.managerId !== managerId) {
|
||||
throw new OpenClawStateOwnershipClaimConflictError(managerId, current.managerId);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
const ownership: OpenClawExternalStateOwnership = {
|
||||
version: 1,
|
||||
mode: "external",
|
||||
managerId,
|
||||
claimedAt: Date.now(),
|
||||
};
|
||||
const valueJson = JSON.stringify(ownership);
|
||||
const stateDb = getNodeSqliteKysely<OwnershipDatabase>(database);
|
||||
executeSqliteQuerySync(
|
||||
database,
|
||||
stateDb
|
||||
.insertInto("config_machine_state")
|
||||
.values({
|
||||
state_key: STATE_SUPERVISION_KEY,
|
||||
value_json: valueJson,
|
||||
updated_at_ms: ownership.claimedAt,
|
||||
})
|
||||
.onConflict((conflict) =>
|
||||
conflict.column("state_key").doUpdateSet({
|
||||
value_json: valueJson,
|
||||
updated_at_ms: ownership.claimedAt,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return ownership;
|
||||
}
|
||||
|
||||
function repairMalformedOwnershipClaim(
|
||||
databasePath: string,
|
||||
managerId: string,
|
||||
): OpenClawExternalStateOwnership {
|
||||
const database = openNodeSqliteDatabase(databasePath);
|
||||
let walMaintenance: SqliteWalMaintenance | undefined;
|
||||
try {
|
||||
database.exec(`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS};`);
|
||||
assertSqliteIntegrity(database, databasePath);
|
||||
assertOpenClawStateDatabaseForMaintenance(database, { pathname: databasePath });
|
||||
walMaintenance = configureSqliteWalMaintenance(database, {
|
||||
busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
|
||||
checkpointIntervalMs: 0,
|
||||
checkpointMode: "TRUNCATE",
|
||||
databaseLabel: "OpenClaw shared state ownership",
|
||||
databasePath,
|
||||
});
|
||||
const ownership = runSqliteImmediateTransactionSync(
|
||||
database,
|
||||
() => {
|
||||
assertOpenClawStateDatabaseForMaintenance(database, { pathname: databasePath });
|
||||
return claimOwnershipRow(database, databasePath, managerId, true);
|
||||
},
|
||||
{
|
||||
busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
|
||||
databaseLabel: databasePath,
|
||||
operationLabel: "state.ownership.repair",
|
||||
},
|
||||
);
|
||||
requireOwnershipCheckpoint(walMaintenance, databasePath);
|
||||
return ownership;
|
||||
} finally {
|
||||
walMaintenance?.close({ checkpointMode: "PASSIVE" });
|
||||
clearNodeSqliteKyselyCacheForDatabase(database);
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Claim durable shared-state write ownership for the active external supervisor. */
|
||||
export function claimOpenClawStateOwnership(
|
||||
managerId: string,
|
||||
options: OpenClawStateOwnershipOptions = {},
|
||||
): OpenClawExternalStateOwnership {
|
||||
const env = options.env ?? process.env;
|
||||
if (!isGatewayExternallySupervised(env)) {
|
||||
throw new Error(
|
||||
"Claiming external shared-state ownership requires OPENCLAW_SUPERVISOR_MODE=external.",
|
||||
);
|
||||
}
|
||||
const normalizedManagerId = normalizeOpenClawStateManagerId(managerId);
|
||||
try {
|
||||
const database = openOpenClawStateDatabase(options);
|
||||
const ownership = runOpenClawStateWriteTransaction(
|
||||
({ db, path: databasePath }) =>
|
||||
claimOwnershipRow(db, databasePath, normalizedManagerId, false),
|
||||
{ ...options, database },
|
||||
{ operationLabel: "state.ownership.claim" },
|
||||
);
|
||||
requireOwnershipCheckpoint(database.walMaintenance, database.path);
|
||||
return ownership;
|
||||
} catch (error) {
|
||||
if (!(error instanceof OpenClawStateOwnershipMetadataError)) {
|
||||
throw error;
|
||||
}
|
||||
const ownership = repairMalformedOwnershipClaim(
|
||||
resolveDatabasePath(options),
|
||||
normalizedManagerId,
|
||||
);
|
||||
openOpenClawStateDatabase(options);
|
||||
return ownership;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { runDoctorStateSqliteCompact } from "../commands/doctor-state-sqlite-compact.js";
|
||||
import {
|
||||
readConfigHealthStateFromStore,
|
||||
writeConfigHealthStateToStore,
|
||||
} from "../config/io.health-state.js";
|
||||
import { requireNodeSqlite } from "../infra/node-sqlite.js";
|
||||
import { withOpenClawStateStartupMigrationCheckpointDatabase } from "./openclaw-state-db-startup-checkpoint.js";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openExistingOpenClawStateDatabaseReadOnly,
|
||||
openOpenClawStateDatabase,
|
||||
repairOpenClawStateDatabaseSchema,
|
||||
repairOpenClawStateDatabaseSchemaIfNeeded,
|
||||
runOpenClawStateWriteTransaction,
|
||||
} from "./openclaw-state-db.js";
|
||||
import { claimOpenClawStateOwnership } from "./openclaw-state-ownership-operations.js";
|
||||
import {
|
||||
inspectOpenClawStateOwnershipAtPath,
|
||||
OpenClawStateOwnershipError,
|
||||
OpenClawStateOwnershipMetadataError,
|
||||
STATE_SUPERVISION_KEY,
|
||||
} from "./openclaw-state-ownership.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker((cleanup) => {
|
||||
afterEach(() => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
function createEnv(external = false): NodeJS.ProcessEnv {
|
||||
return {
|
||||
OPENCLAW_STATE_DIR: tempDirs.make("openclaw-state-ownership-"),
|
||||
...(external ? { OPENCLAW_SUPERVISOR_MODE: "external" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function withoutExternalMarker(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const next = { ...env };
|
||||
delete next.OPENCLAW_SUPERVISOR_MODE;
|
||||
return next;
|
||||
}
|
||||
|
||||
function claimFixture(managerId = "gateway-supervisor") {
|
||||
const externalEnv = createEnv(true);
|
||||
const ownership = claimOpenClawStateOwnership(managerId, { env: externalEnv });
|
||||
const databasePath = openOpenClawStateDatabase({ env: externalEnv }).path;
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
return { databasePath, externalEnv, ownership, unmarkedEnv: withoutExternalMarker(externalEnv) };
|
||||
}
|
||||
|
||||
function snapshotSqliteFamily(databasePath: string) {
|
||||
const directory = path.dirname(databasePath);
|
||||
const entries = fs.readdirSync(directory).toSorted();
|
||||
return {
|
||||
entries,
|
||||
files: Object.fromEntries(
|
||||
entries.map((entry) => {
|
||||
const pathname = path.join(directory, entry);
|
||||
const stat = fs.statSync(pathname, { bigint: true });
|
||||
return [
|
||||
entry,
|
||||
{
|
||||
bytes: fs.readFileSync(pathname),
|
||||
birthtimeNs: stat.birthtimeNs,
|
||||
ctimeNs: stat.ctimeNs,
|
||||
dev: stat.dev,
|
||||
ino: stat.ino,
|
||||
mode: stat.mode,
|
||||
mtimeNs: stat.mtimeNs,
|
||||
size: stat.size,
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("external shared-state ownership", () => {
|
||||
it("preserves ordinary unowned database behavior", () => {
|
||||
const env = createEnv();
|
||||
const database = openOpenClawStateDatabase({ env });
|
||||
expect(database.db.isOpen).toBe(true);
|
||||
expect(inspectOpenClawStateOwnershipAtPath(database.path)).toBeNull();
|
||||
});
|
||||
|
||||
it("requires the external marker and makes claims idempotent only for one manager", () => {
|
||||
const env = createEnv();
|
||||
expect(() => claimOpenClawStateOwnership("gateway-supervisor", { env })).toThrow(
|
||||
/OPENCLAW_SUPERVISOR_MODE=external/u,
|
||||
);
|
||||
const externalEnv = { ...env, OPENCLAW_SUPERVISOR_MODE: "external" };
|
||||
const first = claimOpenClawStateOwnership("gateway-supervisor", { env: externalEnv });
|
||||
expect(claimOpenClawStateOwnership("gateway-supervisor", { env: externalEnv })).toEqual(first);
|
||||
expect(
|
||||
inspectOpenClawStateOwnershipAtPath(openOpenClawStateDatabase({ env: externalEnv }).path),
|
||||
).toEqual(first);
|
||||
expect(() => claimOpenClawStateOwnership("replacement-manager", { env: externalEnv })).toThrow(
|
||||
/already claimed by external manager gateway-supervisor/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses unmarked writable opens before changing the SQLite family", () => {
|
||||
const fixture = claimFixture();
|
||||
const pending = openOpenClawStateDatabase({ env: fixture.externalEnv });
|
||||
pending.db.exec(`
|
||||
ALTER TABLE worktrees DROP COLUMN run_end_cleanup_json;
|
||||
DROP INDEX idx_task_runs_status;
|
||||
`);
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
if (process.platform !== "win32") {
|
||||
fs.chmodSync(fixture.databasePath, 0o666);
|
||||
}
|
||||
for (const suffix of ["-wal", "-shm", "-journal"]) {
|
||||
expect(fs.existsSync(`${fixture.databasePath}${suffix}`)).toBe(false);
|
||||
}
|
||||
const before = snapshotSqliteFamily(fixture.databasePath);
|
||||
|
||||
expect(() => openOpenClawStateDatabase({ env: fixture.unmarkedEnv })).toThrow(
|
||||
OpenClawStateOwnershipError,
|
||||
);
|
||||
|
||||
expect(snapshotSqliteFamily(fixture.databasePath)).toEqual(before);
|
||||
for (const suffix of ["-wal", "-shm", "-journal"]) {
|
||||
expect(fs.existsSync(`${fixture.databasePath}${suffix}`)).toBe(false);
|
||||
}
|
||||
const repaired = openOpenClawStateDatabase({ env: fixture.externalEnv });
|
||||
expect(repaired.db.isOpen).toBe(true);
|
||||
expect(repaired.db.prepare("PRAGMA table_info(worktrees)").all()).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "run_end_cleanup_json" })]),
|
||||
);
|
||||
expect(
|
||||
repaired.db
|
||||
.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'index' AND name = ?")
|
||||
.get("idx_task_runs_status"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("fences a claim made immediately before cold-open schema repair", () => {
|
||||
const env = createEnv();
|
||||
const databasePath = openOpenClawStateDatabase({ env }).path;
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const drifted = new DatabaseSync(databasePath);
|
||||
try {
|
||||
drifted.exec(`
|
||||
ALTER TABLE worktrees DROP COLUMN run_end_cleanup_json;
|
||||
DROP INDEX idx_task_runs_status;
|
||||
`);
|
||||
} finally {
|
||||
drifted.close();
|
||||
}
|
||||
|
||||
const originalExec = Object.getOwnPropertyDescriptor(DatabaseSync.prototype, "exec")?.value as
|
||||
| ((this: import("node:sqlite").DatabaseSync, sql: string) => void)
|
||||
| undefined;
|
||||
if (!originalExec) {
|
||||
throw new Error("DatabaseSync.exec descriptor is unavailable");
|
||||
}
|
||||
let immediateTransactionCount = 0;
|
||||
const exec = vi.spyOn(DatabaseSync.prototype, "exec").mockImplementation(function (
|
||||
this: import("node:sqlite").DatabaseSync,
|
||||
sql: string,
|
||||
) {
|
||||
if (sql === "BEGIN IMMEDIATE" && ++immediateTransactionCount === 1) {
|
||||
const claimant = new DatabaseSync(databasePath);
|
||||
try {
|
||||
claimant
|
||||
.prepare(
|
||||
`INSERT INTO config_machine_state (state_key, value_json, updated_at_ms)
|
||||
VALUES (?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
STATE_SUPERVISION_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
mode: "external",
|
||||
managerId: "race-manager",
|
||||
claimedAt: 1,
|
||||
}),
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
claimant.close();
|
||||
}
|
||||
}
|
||||
return originalExec.call(this, sql);
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => openOpenClawStateDatabase({ env })).toThrow(OpenClawStateOwnershipError);
|
||||
} finally {
|
||||
exec.mockRestore();
|
||||
}
|
||||
expect(immediateTransactionCount).toBe(1);
|
||||
|
||||
const verify = new DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
expect(
|
||||
verify
|
||||
.prepare("SELECT 1 FROM pragma_table_info('worktrees') WHERE name = ?")
|
||||
.get("run_end_cleanup_json"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
verify
|
||||
.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'index' AND name = ?")
|
||||
.get("idx_task_runs_status"),
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
verify.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("fences injected and pre-claim handles on their next canonical write", () => {
|
||||
const externalEnv = createEnv(true);
|
||||
const opened = openOpenClawStateDatabase({ env: externalEnv });
|
||||
claimOpenClawStateOwnership("gateway-supervisor", { env: externalEnv });
|
||||
const unmarkedEnv = withoutExternalMarker(externalEnv);
|
||||
|
||||
expect(() => openOpenClawStateDatabase({ env: unmarkedEnv })).toThrow(
|
||||
OpenClawStateOwnershipError,
|
||||
);
|
||||
expect(() => openOpenClawStateDatabase({ env: unmarkedEnv, database: opened })).toThrow(
|
||||
OpenClawStateOwnershipError,
|
||||
);
|
||||
expect(() =>
|
||||
runOpenClawStateWriteTransaction(() => undefined, {
|
||||
env: unmarkedEnv,
|
||||
database: opened,
|
||||
}),
|
||||
).toThrow(OpenClawStateOwnershipError);
|
||||
});
|
||||
|
||||
it("reports checkpoint failure and lets the same durable claim retry", () => {
|
||||
const env = createEnv(true);
|
||||
const database = openOpenClawStateDatabase({ env });
|
||||
const checkpoint = vi.spyOn(database.walMaintenance, "checkpoint").mockReturnValueOnce(false);
|
||||
|
||||
expect(() => claimOpenClawStateOwnership("gateway-supervisor", { env })).toThrow(
|
||||
/ownership was committed.*checkpoint failed/iu,
|
||||
);
|
||||
checkpoint.mockRestore();
|
||||
const ownership = claimOpenClawStateOwnership("gateway-supervisor", { env });
|
||||
expect(inspectOpenClawStateOwnershipAtPath(database.path)).toEqual(ownership);
|
||||
});
|
||||
|
||||
it("fails closed when unmarked and lets an external claim repair malformed metadata", () => {
|
||||
const env = createEnv(true);
|
||||
const database = openOpenClawStateDatabase({ env });
|
||||
database.db
|
||||
.prepare(
|
||||
"INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run(STATE_SUPERVISION_KEY, '{"version":1,"mode":"external"}', Date.now());
|
||||
database.db.exec("ALTER TABLE worktrees DROP COLUMN run_end_cleanup_json;");
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
|
||||
expect(() => openOpenClawStateDatabase({ env: withoutExternalMarker(env) })).toThrow(
|
||||
OpenClawStateOwnershipMetadataError,
|
||||
);
|
||||
expect(() => openOpenClawStateDatabase({ env })).toThrow(OpenClawStateOwnershipMetadataError);
|
||||
const ownership = claimOpenClawStateOwnership("gateway-supervisor", { env });
|
||||
expect(inspectOpenClawStateOwnershipAtPath(database.path)).toEqual(ownership);
|
||||
expect(
|
||||
openOpenClawStateDatabase({ env }).db.prepare("PRAGMA table_info(worktrees)").all(),
|
||||
).toEqual(expect.arrayContaining([expect.objectContaining({ name: "run_end_cleanup_json" })]));
|
||||
});
|
||||
|
||||
it("does not repair malformed ownership before blocking schema drift", () => {
|
||||
const env = createEnv(true);
|
||||
const database = openOpenClawStateDatabase({ env });
|
||||
const malformed = '{"version":1,"mode":"external"}';
|
||||
database.db
|
||||
.prepare(
|
||||
"INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run(STATE_SUPERVISION_KEY, malformed, Date.now());
|
||||
database.db.exec("ALTER TABLE worktrees ADD COLUMN unexpected_claim_column TEXT DEFAULT NULL;");
|
||||
const databasePath = database.path;
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
|
||||
expect(() => claimOpenClawStateOwnership("gateway-supervisor", { env })).toThrow(
|
||||
/column definitions differ for worktrees/u,
|
||||
);
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const raw = new DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
expect(
|
||||
raw
|
||||
.prepare("SELECT value_json FROM config_machine_state WHERE state_key = ?")
|
||||
.get(STATE_SUPERVISION_KEY),
|
||||
).toEqual({ value_json: malformed });
|
||||
} finally {
|
||||
raw.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("fences Doctor repair, startup checkpoint, compaction, and config health", async () => {
|
||||
const fixture = claimFixture();
|
||||
if (process.platform !== "win32") {
|
||||
fs.chmodSync(fixture.databasePath, 0o666);
|
||||
}
|
||||
const before = snapshotSqliteFamily(fixture.databasePath);
|
||||
expect(() => repairOpenClawStateDatabaseSchema({ env: fixture.unmarkedEnv })).toThrow(
|
||||
OpenClawStateOwnershipError,
|
||||
);
|
||||
expect(() => repairOpenClawStateDatabaseSchemaIfNeeded({ env: fixture.unmarkedEnv })).toThrow(
|
||||
OpenClawStateOwnershipError,
|
||||
);
|
||||
expect(() =>
|
||||
withOpenClawStateStartupMigrationCheckpointDatabase(() => undefined, {
|
||||
env: fixture.unmarkedEnv,
|
||||
}),
|
||||
).toThrow(OpenClawStateOwnershipError);
|
||||
await expect(runDoctorStateSqliteCompact({ env: fixture.unmarkedEnv })).rejects.toThrow(
|
||||
OpenClawStateOwnershipError,
|
||||
);
|
||||
const healthDeps = {
|
||||
env: fixture.unmarkedEnv,
|
||||
homedir: () => fixture.unmarkedEnv.OPENCLAW_STATE_DIR ?? "",
|
||||
logger: { warn: () => undefined },
|
||||
};
|
||||
expect(() => readConfigHealthStateFromStore(healthDeps)).toThrow(OpenClawStateOwnershipError);
|
||||
expect(() =>
|
||||
writeConfigHealthStateToStore(healthDeps, {
|
||||
entries: { "/tmp/openclaw.json": { lastObservedSuspiciousSignature: "test" } },
|
||||
}),
|
||||
).toThrow(OpenClawStateOwnershipError);
|
||||
expect(snapshotSqliteFamily(fixture.databasePath)).toEqual(before);
|
||||
});
|
||||
|
||||
it("allows read-only access without the external marker", async () => {
|
||||
const fixture = claimFixture();
|
||||
const database = await openExistingOpenClawStateDatabaseReadOnly({ env: fixture.unmarkedEnv });
|
||||
expect(database?.db.isOpen).toBe(true);
|
||||
database?.walMaintenance.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { isGatewayExternallySupervised } from "../infra/gateway-supervision.js";
|
||||
import { openNodeSqliteDatabase, resolveImmutableSqliteFileUri } from "../infra/node-sqlite.js";
|
||||
import { tableExists } from "./openclaw-state-db-schema-helpers.js";
|
||||
|
||||
export const STATE_SUPERVISION_KEY = "gateway.supervision";
|
||||
const MAX_OWNERSHIP_TIMESTAMP_MS = 8_640_000_000_000_000;
|
||||
const MANAGER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
|
||||
|
||||
export type OpenClawExternalStateOwnership = {
|
||||
claimedAt: number;
|
||||
managerId: string;
|
||||
mode: "external";
|
||||
version: 1;
|
||||
};
|
||||
|
||||
export class OpenClawStateOwnershipError extends Error {}
|
||||
|
||||
export class OpenClawStateOwnershipMetadataError extends OpenClawStateOwnershipError {
|
||||
constructor(
|
||||
readonly databasePath: string,
|
||||
message: string,
|
||||
) {
|
||||
super(
|
||||
`OpenClaw shared state ownership metadata is invalid at ${databasePath}: ${message}. ` +
|
||||
"Repair it with OPENCLAW_SUPERVISOR_MODE=external openclaw database ownership claim --manager <manager-id>.",
|
||||
);
|
||||
this.name = "OpenClawStateOwnershipMetadataError";
|
||||
}
|
||||
}
|
||||
|
||||
class OpenClawStateExternalOwnershipError extends OpenClawStateOwnershipError {
|
||||
constructor(
|
||||
readonly databasePath: string,
|
||||
readonly managerId: string,
|
||||
) {
|
||||
super(
|
||||
`OpenClaw shared state database ${databasePath} is externally supervised by ${managerId}. ` +
|
||||
"Use that external supervisor with OPENCLAW_SUPERVISOR_MODE=external for writable operations.",
|
||||
);
|
||||
this.name = "OpenClawStateExternalOwnershipError";
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeOpenClawStateManagerId(managerId: string): string {
|
||||
const normalized = managerId.trim();
|
||||
if (!MANAGER_ID_PATTERN.test(normalized)) {
|
||||
throw new Error(
|
||||
"External state ownership manager id must be a 1-128 character ASCII identifier.",
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseExternalOwnership(
|
||||
valueJson: string,
|
||||
databasePath: string,
|
||||
): OpenClawExternalStateOwnership {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(valueJson) as unknown;
|
||||
} catch {
|
||||
throw new OpenClawStateOwnershipMetadataError(databasePath, "reserved value is not valid JSON");
|
||||
}
|
||||
const record = isRecord(value) ? value : undefined;
|
||||
const keys = record ? Object.keys(record).toSorted().join(",") : "";
|
||||
const managerId = record?.managerId;
|
||||
const claimedAt = record?.claimedAt;
|
||||
if (
|
||||
keys !== "claimedAt,managerId,mode,version" ||
|
||||
record?.version !== 1 ||
|
||||
record?.mode !== "external" ||
|
||||
typeof managerId !== "string" ||
|
||||
!MANAGER_ID_PATTERN.test(managerId) ||
|
||||
typeof claimedAt !== "number" ||
|
||||
!Number.isSafeInteger(claimedAt) ||
|
||||
claimedAt < 0 ||
|
||||
claimedAt > MAX_OWNERSHIP_TIMESTAMP_MS
|
||||
) {
|
||||
throw new OpenClawStateOwnershipMetadataError(
|
||||
databasePath,
|
||||
"reserved value does not match the version 1 external ownership contract",
|
||||
);
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
mode: "external",
|
||||
managerId,
|
||||
claimedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Inspect the reserved ownership row without entering the shared-state lifecycle. */
|
||||
export function inspectOpenClawStateOwnershipFromDatabase(
|
||||
database: DatabaseSync,
|
||||
databasePath: string,
|
||||
): OpenClawExternalStateOwnership | null {
|
||||
if (!tableExists(database, "config_machine_state")) {
|
||||
return null;
|
||||
}
|
||||
const row = database
|
||||
.prepare("SELECT value_json FROM config_machine_state WHERE state_key = ? LIMIT 1")
|
||||
.get(STATE_SUPERVISION_KEY) as { value_json?: unknown } | undefined;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
if (typeof row.value_json !== "string") {
|
||||
throw new OpenClawStateOwnershipMetadataError(databasePath, "reserved value is not text");
|
||||
}
|
||||
return parseExternalOwnership(row.value_json, databasePath);
|
||||
}
|
||||
|
||||
/** Inspect one resolved state database path through a read-only connection. */
|
||||
export function inspectOpenClawStateOwnershipAtPath(
|
||||
databasePath: string,
|
||||
): OpenClawExternalStateOwnership | null {
|
||||
const resolvedPath = path.resolve(databasePath);
|
||||
if (!existsSync(resolvedPath)) {
|
||||
return null;
|
||||
}
|
||||
const database = openNodeSqliteDatabase(resolveImmutableSqliteFileUri(resolvedPath), {
|
||||
readOnly: true,
|
||||
});
|
||||
try {
|
||||
database.exec("PRAGMA query_only = ON; PRAGMA trusted_schema = OFF;");
|
||||
return inspectOpenClawStateOwnershipFromDatabase(database, resolvedPath);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Fence shared-state writes once an external manager has claimed ownership. */
|
||||
export function assertOpenClawStateWriteAllowed(options: {
|
||||
database?: DatabaseSync;
|
||||
databasePath: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): void {
|
||||
const resolvedPath = path.resolve(options.databasePath);
|
||||
const status = options.database
|
||||
? inspectOpenClawStateOwnershipFromDatabase(options.database, resolvedPath)
|
||||
: inspectOpenClawStateOwnershipAtPath(resolvedPath);
|
||||
if (status && !isGatewayExternallySupervised(options.env ?? process.env)) {
|
||||
throw new OpenClawStateExternalOwnershipError(resolvedPath, status.managerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
getCanonicalSqliteNamedIndexContracts,
|
||||
type SqliteSchemaCompatibility,
|
||||
type SqliteSchemaIssue,
|
||||
} from "../infra/sqlite-schema-contract.js";
|
||||
import {
|
||||
FIRST_USE_STATE_INDEXES,
|
||||
FIRST_USE_STATE_TABLES,
|
||||
LAZY_ADDITIVE_STATE_INDEXES,
|
||||
LAZY_ADDITIVE_STATE_TABLES,
|
||||
} from "./openclaw-state-db-contract.js";
|
||||
import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js";
|
||||
|
||||
// Same-version databases may lack additive columns that only a writable open
|
||||
// can ensure, while read-only planning must keep accepting the older shape.
|
||||
const CLAW_LAZY_ADDITIVE_STATE_COLUMNS = [
|
||||
"claw_installs.bootstrap_content_digest",
|
||||
"claw_installs.bootstrap_source_path",
|
||||
"worker_environments.desktop_json",
|
||||
"claw_package_refs.extension_adapter_identity",
|
||||
"claw_package_refs.extension_detected_format",
|
||||
"claw_package_refs.extension_format",
|
||||
"claw_package_refs.extension_id",
|
||||
"claw_package_refs.extension_mapped_json",
|
||||
"claw_package_refs.extension_unavailable_json",
|
||||
"worker_environments.shared_host",
|
||||
"worktrees.run_end_cleanup_json",
|
||||
] as const;
|
||||
|
||||
const CLAW_LAZY_ADDITIVE_STATE_COLUMN_SET = new Set<string>(CLAW_LAZY_ADDITIVE_STATE_COLUMNS);
|
||||
let openClawStateCanonicalNamedIndexSet: ReadonlySet<string> | undefined;
|
||||
|
||||
function getOpenClawStateCanonicalNamedIndexSet(): ReadonlySet<string> {
|
||||
openClawStateCanonicalNamedIndexSet ??= new Set(
|
||||
getCanonicalSqliteNamedIndexContracts(OPENCLAW_STATE_SCHEMA_SQL).map((index) => index.name),
|
||||
);
|
||||
return openClawStateCanonicalNamedIndexSet;
|
||||
}
|
||||
|
||||
/** Project canonical SQL to the tables the shared runtime may create during this open. */
|
||||
export function getOpenClawStateRuntimeSchema(options: {
|
||||
includeVersionLazyAdditiveTables: boolean;
|
||||
}): string {
|
||||
let schema = OPENCLAW_STATE_SCHEMA_SQL;
|
||||
const omittedTables = options.includeVersionLazyAdditiveTables
|
||||
? FIRST_USE_STATE_TABLES
|
||||
: LAZY_ADDITIVE_STATE_TABLES;
|
||||
const omittedIndexes = options.includeVersionLazyAdditiveTables
|
||||
? FIRST_USE_STATE_INDEXES
|
||||
: LAZY_ADDITIVE_STATE_INDEXES;
|
||||
for (const tableName of omittedTables) {
|
||||
const start = schema.indexOf(`CREATE TABLE IF NOT EXISTS ${tableName} (`);
|
||||
const endMarker = "\n) STRICT;";
|
||||
const end = start >= 0 ? schema.indexOf(endMarker, start) : -1;
|
||||
if (start < 0 || end < 0) {
|
||||
throw new Error(`lazy additive state schema block is missing for ${tableName}`);
|
||||
}
|
||||
schema = `${schema.slice(0, start)}${schema.slice(end + endMarker.length)}`;
|
||||
}
|
||||
for (const indexName of omittedIndexes) {
|
||||
const start = schema.indexOf(`CREATE INDEX IF NOT EXISTS ${indexName}`);
|
||||
const end = start >= 0 ? schema.indexOf(";", start) : -1;
|
||||
if (start < 0 || end < 0) {
|
||||
throw new Error(`lazy additive state schema index is missing for ${indexName}`);
|
||||
}
|
||||
schema = `${schema.slice(0, start)}${schema.slice(end + 1)}`;
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
export const STATE_PERSISTENT_SCHEMA_COMPATIBILITY: SqliteSchemaCompatibility = {
|
||||
allowCompatibleAdditiveColumns: true,
|
||||
allowedColumnDefinitions: {
|
||||
"diagnostic_events.sequence": ["sequence INTEGER NOT NULL DEFAULT 0"],
|
||||
"commitments.attempts": ["attempts INTEGER NOT NULL DEFAULT 0"],
|
||||
"commitments.confidence": ["confidence REAL NOT NULL DEFAULT 0"],
|
||||
"commitments.created_at_ms": ["created_at_ms INTEGER NOT NULL DEFAULT 0"],
|
||||
"commitments.dedupe_key": ["dedupe_key TEXT NOT NULL DEFAULT ''"],
|
||||
"commitments.due_timezone": ["due_timezone TEXT NOT NULL DEFAULT 'UTC'"],
|
||||
"commitments.kind": ["kind TEXT NOT NULL DEFAULT 'followup'"],
|
||||
"commitments.reason": ["reason TEXT NOT NULL DEFAULT ''"],
|
||||
"commitments.sensitivity": ["sensitivity TEXT NOT NULL DEFAULT 'normal'"],
|
||||
"commitments.source": ["source TEXT NOT NULL DEFAULT 'unknown'"],
|
||||
"commitments.suggested_text": ["suggested_text TEXT NOT NULL DEFAULT ''"],
|
||||
"claw_package_refs.package_integrity": [
|
||||
"package_integrity TEXT NOT NULL DEFAULT 'sha256:0000000000000000000000000000000000000000000000000000000000000000'",
|
||||
],
|
||||
"claw_package_refs.updated_at_ms": ["updated_at_ms INTEGER NOT NULL DEFAULT 0"],
|
||||
"cron_jobs.created_at_ms": ["created_at_ms INTEGER NOT NULL DEFAULT 0"],
|
||||
"cron_jobs.enabled": ["enabled INTEGER NOT NULL DEFAULT 1"],
|
||||
"cron_jobs.name": ["name TEXT NOT NULL DEFAULT ''"],
|
||||
"cron_jobs.payload_kind": ["payload_kind TEXT NOT NULL DEFAULT 'message'"],
|
||||
"cron_jobs.schedule_kind": ["schedule_kind TEXT NOT NULL DEFAULT 'manual'"],
|
||||
"cron_jobs.session_target": ["session_target TEXT NOT NULL DEFAULT 'main'"],
|
||||
"cron_jobs.wake_mode": ["wake_mode TEXT NOT NULL DEFAULT 'auto'"],
|
||||
"current_conversation_bindings.conversation_kind": [
|
||||
"conversation_kind TEXT NOT NULL DEFAULT 'channel'",
|
||||
],
|
||||
"current_conversation_bindings.target_agent_id": [
|
||||
"target_agent_id TEXT NOT NULL DEFAULT 'main'",
|
||||
],
|
||||
"operator_approvals.resolution_ref": ["resolution_ref TEXT"],
|
||||
"worker_environments.desktop_json": ["desktop_json TEXT"],
|
||||
"worker_environments.shared_host": ["shared_host INTEGER CHECK (shared_host IN (0, 1))"],
|
||||
},
|
||||
};
|
||||
|
||||
export const OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY: SqliteSchemaCompatibility = {
|
||||
...STATE_PERSISTENT_SCHEMA_COMPATIBILITY,
|
||||
allowedMissingTables: LAZY_ADDITIVE_STATE_TABLES,
|
||||
allowedMissingColumns: CLAW_LAZY_ADDITIVE_STATE_COLUMNS,
|
||||
};
|
||||
|
||||
/** Identify schema differences that the writable shared-state cold open repairs. */
|
||||
export function isOpenClawStateStartupRepairableSchemaIssue(issue: SqliteSchemaIssue): boolean {
|
||||
if (issue.code === "missing-column") {
|
||||
return CLAW_LAZY_ADDITIVE_STATE_COLUMN_SET.has(issue.objectName);
|
||||
}
|
||||
return (
|
||||
issue.code === "missing-or-drifted-index" &&
|
||||
getOpenClawStateCanonicalNamedIndexSet().has(issue.objectName)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createOpenClawTestInstance } from "./helpers/openclaw-test-instance.js";
|
||||
|
||||
describe("Gateway external shared-state ownership", () => {
|
||||
it("refuses unmarked startup and accepts the external supervisor marker", async () => {
|
||||
const instance = await createOpenClawTestInstance({
|
||||
name: "gateway-external-state-owner",
|
||||
env: { OPENCLAW_SUPERVISOR_MODE: "external" },
|
||||
startTimeoutMs: 30_000,
|
||||
});
|
||||
try {
|
||||
const claim = await instance.cli([
|
||||
"database",
|
||||
"ownership",
|
||||
"claim",
|
||||
"--manager",
|
||||
"gateway-supervisor",
|
||||
"--json",
|
||||
]);
|
||||
expect(claim.code, claim.stderr).toBe(0);
|
||||
const claimed = JSON.parse(claim.stdout) as {
|
||||
databasePath: string;
|
||||
ownership: { managerId: string };
|
||||
status: string;
|
||||
};
|
||||
expect(claimed).toMatchObject({
|
||||
status: "external",
|
||||
ownership: { managerId: "gateway-supervisor" },
|
||||
});
|
||||
const preflight = await instance.cli([
|
||||
"database",
|
||||
"preflight",
|
||||
claimed.databasePath,
|
||||
"--json",
|
||||
]);
|
||||
expect(preflight.code, preflight.stderr).toBe(0);
|
||||
expect(JSON.parse(preflight.stdout)).toMatchObject({
|
||||
schema: "openclaw.state-schema-preflight.v1",
|
||||
status: "exact",
|
||||
requiresWrite: false,
|
||||
});
|
||||
const unreadable = await instance.cli([
|
||||
"database",
|
||||
"preflight",
|
||||
`${instance.stateDir}/missing.sqlite`,
|
||||
"--json",
|
||||
]);
|
||||
expect(unreadable.code).toBe(1);
|
||||
expect(JSON.parse(unreadable.stdout)).toMatchObject({
|
||||
schema: "openclaw.state-schema-preflight.v1",
|
||||
status: "indeterminate",
|
||||
});
|
||||
const status = await instance.cli(["database", "ownership", "status", "--json"]);
|
||||
expect(status.code, status.stderr).toBe(0);
|
||||
expect(JSON.parse(status.stdout)).toMatchObject({
|
||||
status: "external",
|
||||
ownership: { managerId: "gateway-supervisor" },
|
||||
});
|
||||
const conflictingClaim = await instance.cli([
|
||||
"database",
|
||||
"ownership",
|
||||
"claim",
|
||||
"--manager",
|
||||
"replacement-manager",
|
||||
"--json",
|
||||
]);
|
||||
expect(conflictingClaim.code).toBe(1);
|
||||
expect(JSON.parse(conflictingClaim.stdout)).toMatchObject({
|
||||
error: expect.stringContaining("already claimed by external manager gateway-supervisor"),
|
||||
});
|
||||
|
||||
delete instance.env.OPENCLAW_SUPERVISOR_MODE;
|
||||
await expect(instance.startGateway()).rejects.toThrow(/gateway-supervisor/u);
|
||||
expect(instance.logs()).toMatch(/OPENCLAW_SUPERVISOR_MODE=external/u);
|
||||
|
||||
instance.env.OPENCLAW_SUPERVISOR_MODE = "external";
|
||||
await instance.startGateway();
|
||||
expect(instance.child).toBeDefined();
|
||||
} finally {
|
||||
await instance.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user