mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
Compare commits
633 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0099ce7cb4 | |||
| c6ae84de81 | |||
| 953a224245 | |||
| dbcf485d8a | |||
| 14d9bddf46 | |||
| 630803d5a3 | |||
| 99fff5d5af | |||
| 807343c82c | |||
| 25c3249ce2 | |||
| 967c3e4209 | |||
| 52daf8129d | |||
| 1d4bfea63b | |||
| 911a42fa10 | |||
| c88c1c7b4e | |||
| ed57e44e3c | |||
| b183e80565 | |||
| a190bd6a13 | |||
| 4407589b62 | |||
| 2124bce7ca | |||
| 30ab8810cc | |||
| 6b28adfacf | |||
| 1e19d8114a | |||
| 8395dd5019 | |||
| 87e3bfd0e6 | |||
| fff3a7589c | |||
| 6388c28b91 | |||
| 7df31befb5 | |||
| 90f3efdd50 | |||
| 9632604958 | |||
| 39f02b5d7a | |||
| cf0cba0679 | |||
| 88ed98b013 | |||
| 40c2212dd3 | |||
| df6aa9ff4d | |||
| c27da41b64 | |||
| 353a5f9f75 | |||
| 05606dfb56 | |||
| b7e5e811d1 | |||
| df7ad453cc | |||
| 1f1f4efec7 | |||
| 8aa0753b12 | |||
| bec1352ef7 | |||
| ef88811ccb | |||
| 455e2f8be5 | |||
| 4f50f6a8f9 | |||
| 30bbe965bf | |||
| 33f28ad671 | |||
| 18339ee3d4 | |||
| f975c4f7cc | |||
| c22ca408d4 | |||
| 374289bfda | |||
| 7ce75d1dd0 | |||
| b216175ee2 | |||
| b72b1cfc16 | |||
| 7c5c196ede | |||
| a7c3b6a670 | |||
| c93ff6674f | |||
| 06d5fedbfc | |||
| f064237081 | |||
| f14e9591e0 | |||
| 7af6fee5d5 | |||
| fb5cfbfd30 | |||
| d531d71778 | |||
| 3e8a777817 | |||
| 471d237af0 | |||
| 8931c3559c | |||
| 0d53be2896 | |||
| 6553ac7782 | |||
| 12b4c3e54c | |||
| d1e5c9acd8 | |||
| 14232838dd | |||
| a347c33608 | |||
| 220c469ee7 | |||
| df271ff708 | |||
| c68038ad06 | |||
| 4fb47e65f4 | |||
| 56c650c5b5 | |||
| 6cabad3197 | |||
| 2d0f2ef3df | |||
| d3a91dcc3f | |||
| 6c7090aa8e | |||
| 8b24af1310 | |||
| e48485f27d | |||
| e847a7adb3 | |||
| a617457a3c | |||
| 4ce7110e23 | |||
| 9a5c2fa643 | |||
| 177d771acf | |||
| 6f25618e83 | |||
| b4c4b0a27e | |||
| b8ad06f49f | |||
| 254533c013 | |||
| be79eb4eb7 | |||
| dd333643f2 | |||
| 823bec1272 | |||
| dbaa3bf140 | |||
| f3d85d51fb | |||
| 8ff017b5e8 | |||
| bad6fa785d | |||
| cb339ab0dc | |||
| 3b24d248a7 | |||
| 350bd422f5 | |||
| d7c851bc47 | |||
| db9d12f8c9 | |||
| b22f8633ba | |||
| 578b4618ce | |||
| 99fca9ee09 | |||
| 608cce19c9 | |||
| 6e5ed979e4 | |||
| f7a86d3608 | |||
| 22a491ff37 | |||
| 5193bd0451 | |||
| 53fc3a81dc | |||
| 9ea0e2bdff | |||
| f29ac19673 | |||
| 2ff0cb87c9 | |||
| 4412093d05 | |||
| 50370971ec | |||
| 2cb8d9a892 | |||
| 9f79fb9969 | |||
| 8b746e1d0e | |||
| 31f3af61c5 | |||
| bbd7f72c2d | |||
| ea97b64407 | |||
| 49f9d1076f | |||
| e568b96388 | |||
| 7786f416f8 | |||
| 64fe1658d4 | |||
| d3d7c783a9 | |||
| 107251891f | |||
| 86c750077f | |||
| d043e7366f | |||
| e0f2309520 | |||
| 53ae3007d0 | |||
| b624de9620 | |||
| 4d65e57f9a | |||
| ed8491addf | |||
| b43c429544 | |||
| 8bd59511a8 | |||
| 696809899e | |||
| 4acb529b85 | |||
| b3c4d6cb99 | |||
| 6011effade | |||
| ad8384182c | |||
| afaa8f79eb | |||
| 39a81f7723 | |||
| c0869cb878 | |||
| b6f2221280 | |||
| 1023fa3adb | |||
| 4653afc554 | |||
| 9c0f31fad0 | |||
| c77b5358e2 | |||
| efa75558af | |||
| ae8bbd98f8 | |||
| 84eaf3f555 | |||
| fd83496a05 | |||
| 020ebebfaa | |||
| 8c4955c835 | |||
| d80f9d209b | |||
| 1b1c45206c | |||
| 84946977bf | |||
| 976f9b15ae | |||
| 5df282f296 | |||
| 2c415638fd | |||
| 2654eb6e2c | |||
| 45bf1d8933 | |||
| 6932b54a30 | |||
| 00ab727791 | |||
| 101cb2ff7a | |||
| 2c8020188d | |||
| 3d2038a251 | |||
| 643bfca996 | |||
| c4068efd54 | |||
| f7fd980429 | |||
| 22ecafdbc2 | |||
| a529700ed3 | |||
| 18820b17f6 | |||
| 03f48a9a80 | |||
| c66399eedc | |||
| bf364f4036 | |||
| ce33997c23 | |||
| 850f99a2c8 | |||
| 58c34ad5c1 | |||
| 381c79417e | |||
| 57b39f7fcf | |||
| 7e6b9e7c2f | |||
| 5b84fd906d | |||
| 686e3ef5a6 | |||
| 4e6209bf61 | |||
| 37ad58699d | |||
| edf0c0e934 | |||
| 1c5731bf75 | |||
| 5971e2a9ed | |||
| 4f30e2d57b | |||
| f4975d6732 | |||
| 85b383a4e0 | |||
| b68060255f | |||
| 0035e548a5 | |||
| df351cbd33 | |||
| c52b7c4abf | |||
| 4d6be2fa77 | |||
| 02b93a3b20 | |||
| 867fcb2288 | |||
| 6e12a7fd5d | |||
| cf85a56aa7 | |||
| 528240026d | |||
| 078ad9b2f3 | |||
| 3a61c38dc5 | |||
| 7fca53823a | |||
| 3df621637f | |||
| 692b4b4851 | |||
| 06508b6f13 | |||
| 0ff117da47 | |||
| e434e6dc0a | |||
| f048dc528e | |||
| 25647df3cf | |||
| d8e69bb31b | |||
| 98d2c1cbd0 | |||
| acad091127 | |||
| 299ac30ee4 | |||
| fc7fae7b0e | |||
| 48211fa587 | |||
| 9213ee79b3 | |||
| a296818922 | |||
| 3570ccd5c3 | |||
| 33294b3fae | |||
| 0b4e928d1a | |||
| 0bc19e34cb | |||
| b92ba54707 | |||
| e19bf36580 | |||
| 9f8890d159 | |||
| f2ab94501c | |||
| 43dc97e48c | |||
| 33b38f8687 | |||
| 9bd012a271 | |||
| 65c7c282f5 | |||
| 530a9dd3ee | |||
| c9b702d234 | |||
| 416603b616 | |||
| fb6a3edb89 | |||
| 1f348fd425 | |||
| dc49b72c29 | |||
| df3c5ae756 | |||
| 56ef70d81f | |||
| b259f35bb4 | |||
| e750fadacd | |||
| 5a00879b63 | |||
| b6bfa120fc | |||
| a4c3f70f0b | |||
| 75f226cfbf | |||
| 94c5aa4cd3 | |||
| a25bf7a4f9 | |||
| 4efc2c92b7 | |||
| ef6296bd22 | |||
| c53a5461ab | |||
| d853a41eae | |||
| a6ab158855 | |||
| 3792313797 | |||
| 40de54fe5b | |||
| 360f4f432c | |||
| ae3b792e16 | |||
| 81739f9cb4 | |||
| 2b69f4d8d7 | |||
| b56f27632a | |||
| fc733d0295 | |||
| 453f7e5d90 | |||
| 63e3b85294 | |||
| e66dea9090 | |||
| 451ce52592 | |||
| bbf79199aa | |||
| 40232d86ba | |||
| 55db1c01ec | |||
| 3528323b50 | |||
| 2cb339258c | |||
| 63007fc8c7 | |||
| 4f8e7e66e3 | |||
| ed1efd3d54 | |||
| efd8268a5d | |||
| 0dd2cf9e80 | |||
| be5d067af9 | |||
| 3e5f3cc562 | |||
| d73aa5f9f0 | |||
| c9fc9c0eea | |||
| cdc7474d7e | |||
| 983cce3e80 | |||
| 76e9d5b0e7 | |||
| 6b75706b8e | |||
| 59fd8cda7b | |||
| cb94081ebd | |||
| 3de0a54d47 | |||
| e7b8fb2f99 | |||
| 454542c1d0 | |||
| 580c51a73a | |||
| 6b7df5a6b5 | |||
| 497edfd564 | |||
| d7d510ec97 | |||
| f4ec58c0e6 | |||
| 2ba28417b3 | |||
| 3f97193280 | |||
| 2aa0a70762 | |||
| 55c1fcd5f9 | |||
| e5d2e4b6d5 | |||
| 4f360cc7a9 | |||
| da43de56ac | |||
| f538670e1f | |||
| 2f60329886 | |||
| c76c5a697b | |||
| 9609bec8b0 | |||
| 9b89d3c630 | |||
| e87f445218 | |||
| 40eee778ac | |||
| 6c128e0be5 | |||
| 7d03154a8a | |||
| 7f8e257d33 | |||
| 4d78bc1c38 | |||
| 1813bbdba3 | |||
| aa4faddade | |||
| ab70633e9f | |||
| e1b439d6a5 | |||
| 4962bf90d1 | |||
| c455be3002 | |||
| ab0e5fecf2 | |||
| f5c9da03f4 | |||
| a16be014d4 | |||
| c85373ff47 | |||
| d7b8f44f90 | |||
| 85321888e0 | |||
| 65c02ef7a5 | |||
| 63a24da9ee | |||
| e5f6b0ad90 | |||
| 111d2900d7 | |||
| 73d8173018 | |||
| 4ecec2cf1b | |||
| 86fad02c41 | |||
| 600a7acdfb | |||
| 288b74d104 | |||
| ce28f63659 | |||
| d0414b402a | |||
| 699e95f12c | |||
| 4e3d79c001 | |||
| 5aaaf8f197 | |||
| b76f707c8b | |||
| f1f6bb858a | |||
| 305d609d0d | |||
| 5a8722ff07 | |||
| b66cf3ae9c | |||
| a8fcf6087a | |||
| 30acdb91e8 | |||
| 124ce9bebb | |||
| 635c1760ad | |||
| 69d39d906f | |||
| 09ec9d0af3 | |||
| 7ca6dbe272 | |||
| ef97ca5c7e | |||
| cc86370e50 | |||
| 60afdf7c7b | |||
| 9b1ef1cf7f | |||
| 8ea4c7e13e | |||
| a4db79e9bd | |||
| 8b1eb9ca0d | |||
| e36db8a56c | |||
| 37d5a87c9b | |||
| 64fbaa7ef1 | |||
| e52592b16d | |||
| 6a53619dd2 | |||
| fa689aedbc | |||
| b9b42e3174 | |||
| 20830d31bd | |||
| 284eec54f7 | |||
| a3ad0d2c97 | |||
| 92322c7a22 | |||
| f499645cdc | |||
| 776c6b397c | |||
| db17965b1a | |||
| ae35b4e733 | |||
| c6d22549ce | |||
| 9f15f42b47 | |||
| a4588b05e0 | |||
| 1625cfb2fb | |||
| 9c1369186f | |||
| fbbb75095f | |||
| 290427d5fa | |||
| 72dbfac3fc | |||
| 018f237384 | |||
| ef28cdc3ad | |||
| b1f2241d0a | |||
| fae83ab0fa | |||
| b498117f32 | |||
| ef4e9c9edc | |||
| 5fd056f99f | |||
| 08fac67f2a | |||
| 9cac25696a | |||
| a938a5e67f | |||
| e0eb4e5975 | |||
| 08e61ce833 | |||
| c293a255d0 | |||
| 1855b48028 | |||
| 2310e1d12a | |||
| f5ee19405f | |||
| de605834b7 | |||
| 5a6ffd600a | |||
| bdbc42c22d | |||
| 84626f9613 | |||
| 932f0c65b9 | |||
| 206f8b59c9 | |||
| a25a570973 | |||
| 681de3b8cc | |||
| a4f6c2ae34 | |||
| 6e6bce6a03 | |||
| ef5d5b46af | |||
| a1f6459c09 | |||
| 280bb63cbd | |||
| f52c8db21a | |||
| d490030b75 | |||
| 200525a1ab | |||
| cac6e88bc0 | |||
| 080be1e03a | |||
| d0a9958711 | |||
| a98d266278 | |||
| 00593742c3 | |||
| 13e9fead35 | |||
| 83b5475d69 | |||
| 990e3a6f51 | |||
| 1cad0c5c55 | |||
| 9c6c697d95 | |||
| 410ff14795 | |||
| 1f391e9ec2 | |||
| 6fa7034172 | |||
| e766066a75 | |||
| 2648f3f3b7 | |||
| 1b2e083aec | |||
| dd991909af | |||
| e5b3a60f73 | |||
| beae8d2dd9 | |||
| 3d25a377cb | |||
| 9af84c8ed6 | |||
| 13f823e5e3 | |||
| dac1c979d1 | |||
| de8a21a49f | |||
| 03fa5d948d | |||
| fcf17db2e7 | |||
| 22c2ad5c73 | |||
| 1135347a96 | |||
| b659f47b62 | |||
| 6066eb114b | |||
| 50c45db561 | |||
| eb99628cd6 | |||
| f826807cf1 | |||
| 333e45572a | |||
| 3a05e7f09c | |||
| 92dc6a59c0 | |||
| a27113feb2 | |||
| a44043262e | |||
| 9550fa6ff3 | |||
| d3ba767087 | |||
| ca21e0eca7 | |||
| 6a8f9a7aea | |||
| e057cd5de5 | |||
| 82155b8599 | |||
| f129a9cfca | |||
| 9fa38dcfdc | |||
| 9515f0811b | |||
| c6df73e522 | |||
| bfa00ae6ac | |||
| 6a913701b9 | |||
| ff60f1c699 | |||
| e50e600bc8 | |||
| 0adc5e8189 | |||
| dfcbdfe47c | |||
| 5016103774 | |||
| 363d2cb4cc | |||
| 34de1639f5 | |||
| 7f00ed6a65 | |||
| 3e8e879661 | |||
| 239711cea7 | |||
| 953af7d8db | |||
| b3ed94342c | |||
| 84ae4ec27e | |||
| 1425fd1dbb | |||
| 2a3b9f8a5f | |||
| de58b118b4 | |||
| cb018b3b60 | |||
| aee457093d | |||
| 5698169f20 | |||
| 922f764241 | |||
| 5b13d5b464 | |||
| 8b19ffed90 | |||
| dbabe8b8d7 | |||
| 1d37242a8b | |||
| ce9423f877 | |||
| 66e0a69666 | |||
| 26a1e71a27 | |||
| acd430836f | |||
| bc18800b58 | |||
| 9a18bea324 | |||
| 4bf8a4806e | |||
| 9453af2aa0 | |||
| b262880189 | |||
| 04280a7b11 | |||
| 40e83a832f | |||
| e5c661f957 | |||
| 49bd850c8b | |||
| 9a6bb04bf5 | |||
| 0df4712831 | |||
| 30aa89f686 | |||
| 26e12594a2 | |||
| eb999eba09 | |||
| f6f8c25930 | |||
| 7b379863b4 | |||
| 5412c385dc | |||
| 895788974c | |||
| f48426dbf8 | |||
| 50d21b431b | |||
| 3228574a8c | |||
| 752101612e | |||
| c9642fb5e2 | |||
| 179a23f1a0 | |||
| 55831efd44 | |||
| c916f566d9 | |||
| 407c1edf74 | |||
| 654d7fd646 | |||
| 16604e5f86 | |||
| 6a1f89936e | |||
| d9df78d2c7 | |||
| 63e3ade7f4 | |||
| 1fe5118029 | |||
| 62fcd245f2 | |||
| 01b80ac376 | |||
| 50533e1837 | |||
| 7c125ccef2 | |||
| 46760d4286 | |||
| 5915b31519 | |||
| 7da4b72d24 | |||
| f14875a8de | |||
| 1eef5336e3 | |||
| 533f783268 | |||
| 6b93320404 | |||
| ad97ca7df1 | |||
| 0221d4de14 | |||
| e8e0326879 | |||
| 2a3a17b6fe | |||
| eb9f6023f0 | |||
| 5e0de01c4b | |||
| 312bc7d4d5 | |||
| 786fe71778 | |||
| b5a1707898 | |||
| 616242fad8 | |||
| 6cbc368dc1 | |||
| c1a1904a67 | |||
| 3023d47eec | |||
| 5dedff1324 | |||
| 8964054fd8 | |||
| 5241055030 | |||
| 983228227e | |||
| bff9e8811d | |||
| 3ebead0d15 | |||
| e6a6182a0c | |||
| db163034c0 | |||
| 69edb1252b | |||
| 15b3c83625 | |||
| 57944398e6 | |||
| 3ed4dec4a3 | |||
| d05acd804f | |||
| df1961c42d | |||
| 1b5d37a8e8 | |||
| ebf644adb7 | |||
| 7aeaa7b0d2 | |||
| 32e4a32462 | |||
| 05ef5ed7bd | |||
| 0ead6348f6 | |||
| 949c021d45 | |||
| 1c76c7158a | |||
| 673061265c | |||
| 50f39a6983 | |||
| 055005688a | |||
| b1c161b255 | |||
| 6b3c0217f0 | |||
| 40e8a7cfa3 | |||
| bff09241d3 | |||
| 73b220de3c | |||
| 92664273ed | |||
| d705f2ff64 | |||
| 8801b3976c | |||
| c6eea8111d | |||
| e06f76436e | |||
| b05d26c9c6 | |||
| 4ac9857fe8 | |||
| f428839602 | |||
| 31f0fb4ceb | |||
| e100441e8a | |||
| d7521dc49a | |||
| d4d214d074 | |||
| 61bc569bd8 | |||
| b41457553d | |||
| 7a62a78fad | |||
| 2ae55da52e | |||
| b0023787c5 | |||
| 7579d3865f | |||
| d14d222ede | |||
| ddda7c44ef | |||
| 957116d983 | |||
| 356b3df81e | |||
| db0d503bb4 | |||
| 2cce8a3175 | |||
| a998f02f2b | |||
| 31253eb23d | |||
| cd3d0c9f82 | |||
| 02f458856d | |||
| d99bd5231a | |||
| 0f65202659 | |||
| b96624a251 | |||
| 3aff95d256 | |||
| 85f91b1ed7 | |||
| e3ef6c9d27 | |||
| c1fbf73f7e | |||
| c6cc8c3305 | |||
| 5c7c995a95 | |||
| b03d2d7ea6 | |||
| f136c9abdb | |||
| 72c74803be | |||
| db68a130ce | |||
| 62bd5d09d4 | |||
| 47c9bc53fa | |||
| 474e3da3b7 | |||
| 72d72810b2 | |||
| 9d0f9f0576 | |||
| 4b72419a96 | |||
| 53a4c92176 | |||
| 589ae08318 | |||
| 70a13dc107 | |||
| 360a26f109 | |||
| 48d414e197 | |||
| 8a782525de |
@@ -0,0 +1,300 @@
|
||||
---
|
||||
name: autoresearch
|
||||
description: >
|
||||
Autonomous goal-directed iteration loop, inspired by Karpathy's autoresearch.
|
||||
Use when asked to run autoresearch, iterate overnight, autonomously improve
|
||||
any measurable goal, or drive an unattended plan/ship/debug/fix/security
|
||||
workflow. Loops forever: modify → verify → keep/revert → log → repeat.
|
||||
Never stops until the user interrupts.
|
||||
---
|
||||
|
||||
# Autoresearch
|
||||
|
||||
> Ported from `supratikpm/gemini-autoresearch` (Gemini CLI). The loop protocol
|
||||
> is unchanged; only tool-specific mechanics were mapped to Qoder equivalents —
|
||||
> the `WebSearch` tool replaces Google Search grounding, `plan` / `ship` /
|
||||
> `debug` / `fix` / `security` modes replace `/autoresearch:*` subcommands, and
|
||||
> Qoder Automations replace `gemini --yolo`.
|
||||
|
||||
You are an autonomous improvement agent. You iterate forever until interrupted.
|
||||
You do not ask "should I continue?" You do not pause for confirmation. You run
|
||||
the loop.
|
||||
|
||||
## Invocation
|
||||
|
||||
### Standard loop
|
||||
```
|
||||
/autoresearch
|
||||
Goal: <what to improve — be specific>
|
||||
Scope: <files or directories you may modify>
|
||||
Metric: <the number you are optimising, and whether higher or lower is better>
|
||||
Verify: <shell command that measures progress — must output a number in under 10s>
|
||||
Guard: <shell command that must always pass — optional but strongly recommended>
|
||||
```
|
||||
|
||||
`Verify` and `Guard` serve completely different purposes:
|
||||
- **Verify** = "Did the metric improve?" — measures progress toward the goal
|
||||
- **Guard** = "Did anything else break?" — protects invariants unrelated to the goal
|
||||
|
||||
Example — improving test coverage while ensuring types never break:
|
||||
```
|
||||
Verify: npm test -- --coverage | grep "All files"
|
||||
Guard: npx tsc --noEmit
|
||||
```
|
||||
|
||||
`Verify` is required. `Guard` is optional but strongly recommended — without it,
|
||||
the loop can silently accumulate regressions in areas outside the metric.
|
||||
|
||||
Guard files are **never modified** by the loop. They are read-only constraints.
|
||||
|
||||
Goal, Scope, Metric, and Verify are required. Guard is optional.
|
||||
If any required fields are missing, ask for them once, then start.
|
||||
|
||||
### Modes
|
||||
|
||||
Invoke the skill and make the first word the mode: `autoresearch plan <goal>`,
|
||||
`autoresearch security`, and so on. Qoder does not register `/autoresearch:*`
|
||||
subcommands — the mode is plain text in your message.
|
||||
|
||||
| Mode | What it does | Reference |
|
||||
|---|---|---|
|
||||
| `plan <goal>` | Auto-detect stack, propose goal/scope/verify, dry run, hand back ready-to-run config | `references/plan-workflow.md` |
|
||||
| `ship` | Pre-flight checklist — tests, types, lint, bundle, secrets, deps. Autoresearch loop on anything that fails | `references/ship-workflow.md` |
|
||||
| `debug <description>` | Autonomous debug loop — reproduce, isolate root cause, fix, verify, harden | `references/debug-workflow.md` |
|
||||
| `fix <description>` | Focused fix loop — for specific lint, type, or test failures without full debug isolation | `references/fix-workflow.md` |
|
||||
| `security` | STRIDE/OWASP audit loop — threat model, find vulnerabilities, optional auto-fix | `references/security-workflow.md` |
|
||||
|
||||
No mode means the standard loop above.
|
||||
|
||||
**When a mode is invoked**, read the corresponding reference file
|
||||
before doing anything else. The reference file contains the full protocol
|
||||
for that workflow.
|
||||
|
||||
---
|
||||
|
||||
## Setup phase (run once before the loop)
|
||||
|
||||
1. Read every file in Scope to build full context. Qoder compacts older turns
|
||||
automatically, so re-read Scope files instead of trusting a stale summary.
|
||||
2. Read `autoresearch-lessons.md` if it exists. This is accumulated knowledge
|
||||
from prior runs. Read it carefully before forming any hypothesis.
|
||||
3. Run the Verify command. Record the output as the baseline (iteration #0).
|
||||
4. If Guard is provided: run it once. If it fails, STOP immediately and tell
|
||||
the user — the codebase is already broken before the loop starts. Fix the
|
||||
Guard failure manually before proceeding. Guard must be green at baseline.
|
||||
5. Initialise `autoresearch-results.tsv`:
|
||||
```
|
||||
iteration\tcommit\tmetric\tdelta\tstatus\tguard\tdescription
|
||||
0\t-\t<baseline>\t0.0\tbaseline\tpass\tinitial measurement
|
||||
```
|
||||
6. Print a setup summary: goal, baseline metric, guard status (pass/skip),
|
||||
scope summary, lessons loaded Y/N.
|
||||
7. Start the loop immediately. Do not wait for confirmation.
|
||||
|
||||
---
|
||||
|
||||
## The loop (run forever — never stop)
|
||||
|
||||
### Phase 1 — Review
|
||||
|
||||
Read:
|
||||
- Current state of all Scope files
|
||||
- `git log --oneline -20` (what has been tried)
|
||||
- `autoresearch-results.tsv` (what worked, what failed, patterns)
|
||||
- `autoresearch-lessons.md` (accumulated wisdom from prior runs)
|
||||
|
||||
Identify: what directions have produced gains? what has consistently failed?
|
||||
what has not been tried yet?
|
||||
|
||||
### Phase 2 — Ideate
|
||||
|
||||
Pick ONE hypothesis. It must be:
|
||||
- Specific and testable in a single iteration
|
||||
- Meaningfully different from the last 3 attempts
|
||||
- Informed by both the results log and the lessons file
|
||||
- Explained in one sentence
|
||||
|
||||
Prefer hypotheses that build on proven wins over untested territory.
|
||||
Prefer simplicity — a small clean change beats a large complex one.
|
||||
|
||||
### Phase 3 — Modify
|
||||
|
||||
Make exactly ONE atomic change in Scope. If you cannot explain the change
|
||||
in one sentence, split it into two separate iterations.
|
||||
|
||||
Do not touch files outside Scope. Do not refactor unrelated code. One thing.
|
||||
|
||||
### Phase 4 — Commit
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "autoresearch iter N: <one-sentence description>"
|
||||
```
|
||||
|
||||
**Commit BEFORE verifying.** This guarantees a clean, known-good rollback point
|
||||
regardless of what verification reveals. Never skip this step.
|
||||
|
||||
### Phase 5 — Verify + Guard
|
||||
|
||||
**Step A — Run Verify.** Extract the numeric metric value.
|
||||
|
||||
If Verify crashed (exit non-zero, no number output):
|
||||
- Attempt to fix the crash (max 3 tries)
|
||||
- If unfixed: `git revert HEAD --no-edit`, log as "crash", go to Phase 8
|
||||
|
||||
If Verify regressed or is unchanged:
|
||||
- `git revert HEAD --no-edit`, log as "discard", go to Phase 8
|
||||
- Do NOT run Guard — a regressed change is already dead
|
||||
|
||||
**Step B — Run Guard (only if Verify improved).** Exit code 0 = pass.
|
||||
|
||||
**Web research supplement**: after Verify passes, use `WebSearch` for
|
||||
additional signal when local scripts cannot capture full quality.
|
||||
See `references/web-research-patterns.md`. Research is a supplement only.
|
||||
|
||||
### Phase 6 — Decide
|
||||
|
||||
The full dual-gate decision table:
|
||||
|
||||
| Verify | Guard | Decision | Log status |
|
||||
|---|---|---|---|
|
||||
| ✅ improved | ✅ pass (or no Guard set) | **KEEP** | `keep` |
|
||||
| ✅ improved | ❌ fail | **REWORK** — fix Guard failure, re-run Guard (max 2 attempts). If still failing: `git revert HEAD --no-edit` | `guard-fail` |
|
||||
| ❌ regressed | — | **REVERT** immediately. Do not run Guard. | `discard` |
|
||||
| ❌ unchanged | — | **REVERT**. Treat unchanged as a regression. | `discard` |
|
||||
| 💥 crashed | — | **FIX** (max 3 attempts), then revert if unfixed. | `crash` |
|
||||
|
||||
**Rework protocol** (when Verify passes but Guard fails):
|
||||
1. Read the Guard failure output carefully
|
||||
2. Make the minimal additional change to satisfy Guard without hurting Verify
|
||||
3. Amend the commit: `git add -A && git commit --amend --no-edit`
|
||||
4. Re-run both Verify AND Guard
|
||||
5. If both pass → KEEP. If Guard still fails after 2 rework attempts → REVERT.
|
||||
|
||||
### Phase 7 — Log
|
||||
|
||||
Append one row to `autoresearch-results.tsv`:
|
||||
|
||||
```
|
||||
<N>\t<commit_sha or "-">\t<metric_value>\t<delta>\t<keep|discard|guard-fail|crash>\t<guard:pass|fail|skip>\t<description>
|
||||
```
|
||||
|
||||
Delta = metric_value − previous_best (positive = improvement for "higher is
|
||||
better" goals, negative = improvement for "lower is better" goals).
|
||||
|
||||
### Phase 8 — Repeat
|
||||
|
||||
Go to Phase 1. Immediately. NEVER STOP.
|
||||
|
||||
---
|
||||
|
||||
## Progress summary (every 10 iterations)
|
||||
|
||||
Print this, then continue immediately:
|
||||
|
||||
```
|
||||
=== Autoresearch progress — iteration N ===
|
||||
Baseline: <value>
|
||||
Current best: <value> (<delta> from baseline)
|
||||
Keeps: <count>
|
||||
Discards: <count>
|
||||
Crashes: <count>
|
||||
Top pattern: <what has worked most consistently>
|
||||
Last 5: <keep/discard/crash sequence>
|
||||
===
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lessons system
|
||||
|
||||
After every 5 KEPT iterations, append to `autoresearch-lessons.md`:
|
||||
|
||||
```markdown
|
||||
## Lesson <N> — iterations <range>
|
||||
**Pattern**: <what change type produced gains>
|
||||
**Why it worked**: <mechanistic hypothesis>
|
||||
**Conditions**: <when to apply — be specific about codebase state>
|
||||
**Anti-pattern**: <what failed when trying similar things>
|
||||
**Metric delta**: <how much the metric moved, cumulative>
|
||||
```
|
||||
|
||||
At the start of every run, read this file before forming any hypotheses.
|
||||
Weight recent lessons more heavily. Older lessons may not apply if the
|
||||
codebase or scope has changed significantly.
|
||||
|
||||
This is the compounding mechanism. Each overnight run starts smarter than
|
||||
the last.
|
||||
|
||||
---
|
||||
|
||||
## Stuck recovery
|
||||
|
||||
After 5 consecutive discards or crashes:
|
||||
|
||||
1. Re-read all Scope files from scratch. Full context, not memory.
|
||||
2. Search the lessons log for near-misses — what came closest to working?
|
||||
3. Try combining two near-miss approaches into one hypothesis.
|
||||
4. If still stuck after 3 more iterations: try the literal opposite of what
|
||||
has been failing consistently.
|
||||
5. If still stuck after 3 more: use `WebSearch` to research the
|
||||
problem space. Search for `[domain] [metric] improvement techniques [year]`.
|
||||
Extract 3 concrete techniques. Use each as the next 3 hypotheses.
|
||||
6. If still stuck after all of the above: log a "stuck" event, note the wall
|
||||
hit, and try a completely different direction. Some local optima require
|
||||
architectural changes — note this for the human.
|
||||
|
||||
---
|
||||
|
||||
## Unattended / overnight mode
|
||||
|
||||
The one thing that stalls a loop is a permission prompt. Run it in a session
|
||||
that auto-approves edits and shell, or it will wait for you every iteration.
|
||||
|
||||
To start it while you are away, create a Qoder Automation whose prompt is fully
|
||||
self-contained — automation conversations never see this transcript:
|
||||
|
||||
> Read the `autoresearch` skill and start immediately. Goal: `<goal>`.
|
||||
> Scope: `<scope>`. Metric: `<metric — higher/lower is better>`.
|
||||
> Verify: `<command>`. Guard: `<command>`. Do not pause, do not ask questions,
|
||||
> iterate until stopped.
|
||||
|
||||
You will wake up to `autoresearch-results.tsv` and `autoresearch-lessons.md`.
|
||||
Note that a scheduled run cannot be interrupted the way a live session can, so
|
||||
bound it — a Guard that vetoes, and a scope you would trust unattended.
|
||||
|
||||
---
|
||||
|
||||
## Non-negotiable rules
|
||||
|
||||
1. **NEVER STOP** until the user manually interrupts the run.
|
||||
2. **ONE change per iteration** — atomic, explainable in one sentence.
|
||||
3. **Mechanical verification only** — no "looks better", no "seems cleaner".
|
||||
If you cannot measure it, you cannot use it as a signal.
|
||||
4. **Commit BEFORE verifying** — always. No exceptions.
|
||||
5. **Auto-revert on regression** — no debate, no "let me try one more thing".
|
||||
6. **Guard is a hard veto** — Verify passing does not mean KEEP. Guard must also pass.
|
||||
7. **Never modify Guard files** — they are read-only invariants, not scope.
|
||||
8. **Read git history before every hypothesis** — it is your short-term memory.
|
||||
9. **Read lessons before every run** — it is your long-term memory.
|
||||
10. **Simplicity wins ties** — equal metric + less code = KEEP.
|
||||
11. **Never touch files outside Scope** — discipline is what makes the loop safe.
|
||||
12. **When in doubt, make the smaller change** — scope creep kills iterations.
|
||||
|
||||
---
|
||||
|
||||
## Reference files
|
||||
|
||||
**Core loop**
|
||||
- `references/loop-protocol.md` — detailed phase-by-phase protocol
|
||||
- `references/results-logging.md` — TSV format, summary templates, examples
|
||||
- `references/lessons-system.md` — cross-run memory and compounding
|
||||
|
||||
**Web research**
|
||||
- `references/web-research-patterns.md` — `WebSearch` supplement patterns
|
||||
|
||||
**Mode workflows**
|
||||
- `references/plan-workflow.md` — `plan` mode — auto-detect and configure
|
||||
- `references/ship-workflow.md` — `ship` mode — pre-flight checklist
|
||||
- `references/debug-workflow.md` — `debug` mode — root cause and fix
|
||||
- `references/fix-workflow.md` — `fix` mode — focused type/lint fix
|
||||
- `references/security-workflow.md` — `security` mode — STRIDE/OWASP audit
|
||||
@@ -0,0 +1,25 @@
|
||||
# `autoresearch debug` mode — Autonomous Debug Loop
|
||||
|
||||
This workflow is triggered by the `debug` mode. It is designed to reproduce, isolate, and fix specific bugs autonomously.
|
||||
|
||||
## Context
|
||||
Use this when something is clearly broken (e.g., a failing test, a crash, or a UI bug).
|
||||
|
||||
## Phase 1: Reproduction
|
||||
1. Create a minimal reproduction script (e.g., `debug/repro.js` or a new test case).
|
||||
2. Run the repro script and verify it fails as expected.
|
||||
3. This repro command becomes your `Verify` command for the loop.
|
||||
|
||||
## Phase 2: Isolation
|
||||
1. Use `Grep` and `Read` to find the code responsible for the failure.
|
||||
2. Form a hypothesis about the root cause.
|
||||
|
||||
## Phase 3: Fix Loop
|
||||
1. Start a standard autoresearch loop with:
|
||||
- **Goal**: Fix the bug identified in the repro script.
|
||||
- **Verify**: The repro command (must exit 0 on success).
|
||||
- **Guard**: Existing test suite and linting.
|
||||
|
||||
## Phase 4: Hardening
|
||||
1. After the fix is verified, add a permanent regression test to the codebase.
|
||||
2. Verify that the fix holds across the entire project.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Fix Workflow (`autoresearch fix` mode)
|
||||
|
||||
The `fix` workflow is a lightweight version of the `debug` loop. It is designed for situations where you have a specific, known failure (e.g., a TypeScript error or a lint violation) and you want to fix it without the overhead of full reproduction and isolation.
|
||||
|
||||
## Protocol
|
||||
|
||||
### 1. Context Loading
|
||||
* Read the error message or description provided in the command.
|
||||
* Identify the affected file(s).
|
||||
* Read the current state of those files.
|
||||
|
||||
### 2. Hypothesis
|
||||
* Form a direct hypothesis on how to fix the specific error.
|
||||
* The fix must be minimal and targeted.
|
||||
|
||||
### 3. Execution
|
||||
* Apply the fix.
|
||||
* Commit the change.
|
||||
|
||||
### 4. Verification
|
||||
* Run the command that triggered the original failure (e.g., `npx tsc` or `npm run lint`).
|
||||
* If a `Guard` is set in the main autoresearch config, run that as well.
|
||||
|
||||
### 5. Decision
|
||||
* If the error is gone and Guard passes: **KEEP**.
|
||||
* If the error persists: **RETRY** (max 3 times) with a different approach.
|
||||
* If it still fails after 3 tries: **REVERT** and report to the user.
|
||||
|
||||
## When to use `fix` vs `debug`
|
||||
* Use **`fix`** for mechanical errors: "Fix the lint error on line 42", "Fix the missing import in `utils.ts`".
|
||||
* Use **`debug`** for logical errors: "The login flow fails for users with specialized characters", "Database connection timeouts under high load".
|
||||
@@ -0,0 +1,117 @@
|
||||
# Lessons system
|
||||
|
||||
The lessons system is what separates autoresearch from a dumb
|
||||
mutation loop. It is the mechanism by which each overnight run starts
|
||||
smarter than the last.
|
||||
|
||||
---
|
||||
|
||||
## The compounding model
|
||||
|
||||
```
|
||||
Night 1: 100 experiments → lessons-v1 written
|
||||
Night 2: reads lessons-v1 → avoids 20 known failures → 80 net-new experiments
|
||||
Night 3: reads lessons-v2 → avoids 35 known failures → faster convergence
|
||||
...
|
||||
```
|
||||
|
||||
Without the lessons system, every run starts from scratch. With it, runs
|
||||
compound — each failure is learned once and never repeated.
|
||||
|
||||
---
|
||||
|
||||
## File location and format
|
||||
|
||||
File: `autoresearch-lessons.md` in your project root.
|
||||
|
||||
Add to `.gitignore` — this is a working file for the agent, not source code.
|
||||
|
||||
```markdown
|
||||
# Autoresearch lessons — <project name>
|
||||
Generated by the autoresearch skill. Do not edit manually.
|
||||
Last updated: <ISO date>
|
||||
|
||||
## Lesson 1 — iterations 1–5
|
||||
**Pattern**: <the type of change that produced gains>
|
||||
**Why it worked**: <mechanistic hypothesis — be specific>
|
||||
**Conditions**: <codebase state where this applies>
|
||||
**Anti-pattern**: <what failed when trying similar approaches>
|
||||
**Metric delta**: <cumulative gain from this pattern, e.g. "+4.2%">
|
||||
|
||||
## Lesson 2 — iterations 6–10
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When to write lessons
|
||||
|
||||
Append a new lesson after every 5 KEPT iterations (not every 5 total
|
||||
iterations). Lessons should only describe what worked.
|
||||
|
||||
Failed patterns are captured implicitly — if a pattern never generates a
|
||||
kept iteration, it never generates a lesson, and the loop naturally
|
||||
deprioritises it via Phase 2's "different from last 3 attempts" rule.
|
||||
|
||||
---
|
||||
|
||||
## What makes a good lesson
|
||||
|
||||
**Good** (specific, mechanistic, conditional):
|
||||
```
|
||||
**Pattern**: Defer non-critical third-party scripts using loading="lazy"
|
||||
**Why it worked**: Removes scripts from the critical render path, reducing
|
||||
Time to Interactive without affecting functionality
|
||||
**Conditions**: Applies to analytics, chat widgets, social embeds — not
|
||||
to scripts required for initial page render
|
||||
**Anti-pattern**: Lazy-loading scripts that are called in the first 500ms
|
||||
of page load caused layout shifts and broke interactions
|
||||
**Metric delta**: +6.8% Lighthouse performance score across 3 iterations
|
||||
```
|
||||
|
||||
**Bad** (vague, not actionable):
|
||||
```
|
||||
**Pattern**: Make things faster
|
||||
**Why it worked**: It improved performance
|
||||
**Conditions**: When performance is bad
|
||||
**Anti-pattern**: When it makes things worse
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to read lessons at the start of a run
|
||||
|
||||
1. Read the full file — do not skip old lessons even if they seem stale.
|
||||
2. For each lesson, assess: does this pattern still apply given the current
|
||||
state of the codebase? If the code it describes has been significantly
|
||||
refactored, downweight it.
|
||||
3. Extract the top 2-3 highest-delta patterns. These are your first
|
||||
hypotheses unless the results log shows they have already been exhausted.
|
||||
4. Extract the anti-patterns. These are your first exclusions — do not
|
||||
generate hypotheses that match these patterns.
|
||||
|
||||
---
|
||||
|
||||
## Cross-project lessons
|
||||
|
||||
For teams running autoresearch across multiple similar projects (e.g.
|
||||
multiple Next.js apps), consider maintaining a shared lessons file at
|
||||
`~/.autoresearch/global-lessons.md`.
|
||||
|
||||
At the start of a run, read both the project-level and global lessons.
|
||||
Project-level lessons take precedence when they conflict with global ones.
|
||||
|
||||
This is optional but significantly accelerates convergence on new projects
|
||||
that share a tech stack with already-researched ones.
|
||||
|
||||
---
|
||||
|
||||
## Lessons file maintenance
|
||||
|
||||
- Do not manually edit the lessons file during a run — the agent reads it
|
||||
at the start of each run and its contents influence hypothesis generation.
|
||||
- After a long run (100+ iterations), review the file and remove lessons
|
||||
that are no longer applicable (e.g. they describe code that no longer
|
||||
exists). Add a comment explaining why the lesson was removed.
|
||||
- The lessons file is cumulative — never delete lessons, only annotate them
|
||||
as superseded if a newer lesson contradicts them.
|
||||
@@ -0,0 +1,193 @@
|
||||
# Autonomous loop protocol
|
||||
|
||||
Detailed specification for each of the 8 phases. The SKILL.md contains the
|
||||
summary version. Read this reference when you need precise guidance on edge
|
||||
cases in any phase.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Review
|
||||
|
||||
**Purpose**: Build a complete, accurate picture of current state before
|
||||
forming any hypothesis. Hypotheses formed without full context waste iterations.
|
||||
|
||||
**What to read**:
|
||||
- Every file in Scope (not just the ones you last touched)
|
||||
- `git log --oneline -20` — what has been attempted, in order
|
||||
- `autoresearch-results.tsv` — the full record of what worked and failed
|
||||
- `autoresearch-lessons.md` — accumulated patterns from prior runs
|
||||
|
||||
**What to extract**:
|
||||
- Current metric trajectory (improving? plateauing? volatile?)
|
||||
- Which change types produced the most gain per iteration
|
||||
- Which change types consistently failed
|
||||
- Which directions have not yet been explored
|
||||
- Any patterns in crash causes
|
||||
|
||||
**Duration**: This phase should take as long as needed to form a genuinely
|
||||
informed hypothesis. Rushing Phase 1 leads to repeated failures.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Ideate
|
||||
|
||||
**Purpose**: Select ONE hypothesis that has the highest expected gain given
|
||||
what is known.
|
||||
|
||||
**Hypothesis selection criteria** (in order of priority):
|
||||
1. Builds directly on a proven pattern from the lessons file
|
||||
2. Explores a direction adjacent to a near-miss (something that almost worked)
|
||||
3. Combines two near-miss approaches that individually failed
|
||||
4. Tries the opposite of what consistently failed
|
||||
5. Applies an externally validated technique (from `WebSearch` research)
|
||||
6. Tries something entirely untested
|
||||
|
||||
**What makes a good hypothesis**:
|
||||
- Specific: "lazy-load the user avatar component" not "improve performance"
|
||||
- Testable: produces a measurable delta in the Verify command
|
||||
- Atomic: one thing changes, one thing is measured
|
||||
- Explainable in one sentence before you make the change
|
||||
|
||||
**What makes a bad hypothesis**:
|
||||
- Vague: "refactor for clarity"
|
||||
- Multi-part: "update the API, add caching, and fix the tests"
|
||||
- Untestable by the Verify command
|
||||
- Identical to something tried in the last 3 iterations
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Modify
|
||||
|
||||
**Purpose**: Implement the hypothesis as a single, clean, minimal change.
|
||||
|
||||
**Rules**:
|
||||
- Touch only files in Scope
|
||||
- Make the smallest change that tests the hypothesis
|
||||
- If the change is getting large, stop and split it — make the first half now,
|
||||
the second half in the next iteration
|
||||
- Do not fix unrelated things you notice while editing
|
||||
- Do not reformat code that is not part of the hypothesis
|
||||
- Leave comments only if they directly explain the change
|
||||
|
||||
**Signs you are over-scoping**:
|
||||
- You have edited more than 3 files
|
||||
- The diff is more than ~50 lines
|
||||
- You are explaining the change with "and also"
|
||||
|
||||
When in doubt, make a smaller change. Smaller changes fail faster and teach more.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Commit
|
||||
|
||||
**Purpose**: Create a clean rollback point before any verification risk.
|
||||
|
||||
**Command**:
|
||||
```bash
|
||||
git add -A && git commit -m "autoresearch iter N: <one-sentence description>"
|
||||
```
|
||||
|
||||
**Commit message format**:
|
||||
- Always prefix with `autoresearch iter N:`
|
||||
- One sentence, present tense, describes the change not the goal
|
||||
- Good: `autoresearch iter 14: lazy-load user avatar to reduce initial bundle`
|
||||
- Bad: `autoresearch iter 14: improve performance`
|
||||
|
||||
**Why commit before verifying**: if the Verify command crashes, hangs, or
|
||||
corrupts state, you can always `git revert HEAD --no-edit` and return to
|
||||
a known-good state. If you verify before committing, a crash during
|
||||
verification leaves you with uncommitted changes and an unknown baseline.
|
||||
|
||||
**Never skip this step**, even if the change feels obviously correct.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Verify
|
||||
|
||||
**Purpose**: Get a single numeric measurement of whether the hypothesis helped.
|
||||
|
||||
**Execution**:
|
||||
1. Run the Verify command exactly as specified by the user
|
||||
2. Extract the numeric metric value
|
||||
3. Optionally supplement with `WebSearch` research (see
|
||||
`references/web-research-patterns.md`)
|
||||
4. Record the raw output for the log
|
||||
|
||||
**Handling slow Verify commands**:
|
||||
If the Verify command takes more than 30 seconds, note this. After the run,
|
||||
recommend the user find a faster proxy metric — slower verification means
|
||||
fewer experiments per hour, which compounds negatively over a full night.
|
||||
|
||||
**Handling non-deterministic Verify commands**:
|
||||
If the metric varies significantly between runs on identical code (>5%
|
||||
variance), note this in the log. Run the Verify command twice and average.
|
||||
Log both values. Recommend the user address flakiness before the next
|
||||
overnight run.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Decide
|
||||
|
||||
**Purpose**: Make a clear, mechanical keep/revert decision. No deliberation.
|
||||
|
||||
**Decision table**:
|
||||
|
||||
| Condition | Action | Log status |
|
||||
|---|---|---|
|
||||
| Metric improved (beyond noise threshold) | Keep commit as-is | `keep` |
|
||||
| Metric unchanged or regressed | `git revert HEAD --no-edit` | `discard` |
|
||||
| Verify crashed with exit code ≠ 0 | Attempt fix (max 3 tries) then revert | `crash` |
|
||||
| Verify hung for >60s | Kill process, revert | `crash` |
|
||||
|
||||
**Noise threshold**: for metrics with variance, an improvement smaller than
|
||||
the variance is not a real improvement. If your metric normally varies ±2%,
|
||||
an improvement of 0.5% is noise — treat it as unchanged and discard.
|
||||
|
||||
**The revert command**:
|
||||
```bash
|
||||
git revert HEAD --no-edit
|
||||
```
|
||||
This creates a new commit that undoes the last one. The history is preserved.
|
||||
Never use `git reset --hard` — it destroys history that the loop needs.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Log
|
||||
|
||||
**Purpose**: Create a permanent, machine-readable record of every iteration.
|
||||
|
||||
**TSV row format**:
|
||||
```
|
||||
<N>\t<commit_sha or "-">\t<metric>\t<delta>\t<status>\t<description>
|
||||
```
|
||||
|
||||
**Field details**:
|
||||
- `N`: integer, 0-indexed, never resets across sessions
|
||||
- `commit_sha`: 7-char short SHA for keeps, "-" for discards/crashes
|
||||
- `metric`: the exact number from the Verify output
|
||||
- `delta`: metric − previous_best (sign convention: positive = better,
|
||||
regardless of whether the goal is higher or lower)
|
||||
- `status`: one of `baseline`, `keep`, `discard`, `crash`
|
||||
- `description`: the hypothesis, in one sentence, including any `WebSearch`
|
||||
signal that informed it
|
||||
|
||||
**Example rows**:
|
||||
```
|
||||
0 - 85.2 0.0 baseline initial measurement
|
||||
1 a1b2c3d 87.1 +1.9 keep lazy-load avatar component
|
||||
2 - 86.5 -0.6 discard tree-shake lodash imports (broke 2 tests)
|
||||
3 - 0.0 0.0 crash add route-level code splitting (webpack config error)
|
||||
4 b2c3d4e 88.3 +1.2 keep move analytics script to defer loading
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Repeat
|
||||
|
||||
Go to Phase 1. Immediately. Do not pause. Do not summarise. Do not ask
|
||||
if the user wants to continue.
|
||||
|
||||
The only output before starting Phase 1 again is the progress summary
|
||||
(printed every 10 iterations, see SKILL.md).
|
||||
|
||||
The loop ends only when the user interrupts the run.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Plan workflow — `autoresearch plan` mode
|
||||
|
||||
Auto-detect the project stack, propose a complete autoresearch configuration,
|
||||
do a dry run, and hand the ready-to-run command back to the user.
|
||||
|
||||
No manual goal/scope/verify required. Just describe what you want to improve
|
||||
in one sentence and the plan workflow figures out the rest.
|
||||
|
||||
---
|
||||
|
||||
## Invocation
|
||||
|
||||
```
|
||||
autoresearch plan <goal in plain english>
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
autoresearch plan improve test coverage
|
||||
autoresearch plan make the app faster
|
||||
autoresearch plan reduce the bundle size
|
||||
autoresearch plan fix all TypeScript errors
|
||||
autoresearch plan improve the SEO of my blog posts
|
||||
autoresearch plan shrink the Docker image
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What the plan workflow does
|
||||
|
||||
### Step 1 — Detect project stack
|
||||
|
||||
Scan the project root for signal files:
|
||||
|
||||
| File found | Stack detected |
|
||||
|---|---|
|
||||
| `package.json` + `jest.config.*` | Node.js + Jest |
|
||||
| `package.json` + `vitest.config.*` | Node.js + Vitest |
|
||||
| `next.config.*` | Next.js |
|
||||
| `Dockerfile` | Docker |
|
||||
| `*.tf` | Terraform |
|
||||
| `.github/workflows/*.yml` | GitHub Actions CI |
|
||||
| `content/blog/*.md` OR `posts/*.md` | Markdown content/blog |
|
||||
| `src/**/*.ts` OR `src/**/*.tsx` | TypeScript project |
|
||||
| `pyproject.toml` OR `setup.py` | Python project |
|
||||
| `requirements.txt` + `pytest` | Python + pytest |
|
||||
| `go.mod` | Go project |
|
||||
| `Cargo.toml` | Rust project |
|
||||
|
||||
Print detected stack. If ambiguous, list the top two candidates and ask
|
||||
the user to confirm before proceeding.
|
||||
|
||||
### Step 2 — Map goal to metric + verify command
|
||||
|
||||
Use the goal description and detected stack to propose:
|
||||
|
||||
| Goal keyword | Metric | Verify command template |
|
||||
|---|---|---|
|
||||
| "test coverage" | coverage % (higher is better) | `npm test -- --coverage \| grep "All files"` |
|
||||
| "bundle size" / "build size" | size in KB (lower is better) | `npm run build 2>&1 \| grep "First Load JS"` |
|
||||
| "TypeScript errors" / "type errors" | error count (lower is better) | `npx tsc --noEmit 2>&1 \| grep -c "error TS" \|\| echo "0"` |
|
||||
| "lighthouse" / "performance score" | score 0-100 (higher is better) | `npx lighthouse http://localhost:3000 --output json --quiet 2>/dev/null \| jq '.categories.performance.score * 100'` |
|
||||
| "docker image" / "image size" | size in MB (lower is better) | `docker build -t bench . -q && docker images bench --format "{{.Size}}"` |
|
||||
| "flaky tests" | failure count (lower is better) | `for i in {1..5}; do npm test 2>&1; done \| grep -c "FAIL" \|\| echo "0"` |
|
||||
| "SEO" / "blog" / "content" | SEO score (higher is better) | `node scripts/seo-score.js <detected content path>` |
|
||||
| "lines of code" / "complexity" | LOC count (lower is better) | `find src/ -name "*.ts" \| xargs wc -l \| tail -1 \| awk '{print $1}'` |
|
||||
| "CI pipeline" / "pipeline speed" | seconds (lower is better) | `node scripts/estimate-ci-time.js` |
|
||||
| "Python tests" / "pytest" | coverage % (higher is better) | `pytest --cov=src --cov-report=term-missing \| grep "TOTAL"` |
|
||||
| "faster" / "performance" / "latency" | p95 ms (lower is better) | `npm run bench 2>&1 \| grep "p95"` |
|
||||
|
||||
### Step 3 — Detect scope
|
||||
|
||||
Based on goal + stack, propose the tightest scope that covers the goal:
|
||||
|
||||
- Test coverage → `src/**/*.ts, src/**/*.test.ts`
|
||||
- Bundle size → `src/**/*.tsx, src/**/*.ts`
|
||||
- Docker → `Dockerfile, .dockerignore`
|
||||
- SEO → `content/blog/*.md` or detected content directory
|
||||
- TypeScript errors → `src/**/*.ts`
|
||||
- CI pipeline → `.github/workflows/*.yml`
|
||||
|
||||
### Step 4 — Dry run
|
||||
|
||||
Run the proposed Verify command once against the current state.
|
||||
|
||||
- If it exits 0 and outputs a number → baseline confirmed, proceed
|
||||
- If it exits non-zero → diagnose and fix the verify command before proposing
|
||||
- If it hangs → propose a faster alternative
|
||||
|
||||
### Step 5 — Output the ready-to-run command
|
||||
|
||||
Print this exact block for the user to copy-paste or confirm:
|
||||
|
||||
```
|
||||
=== Autoresearch plan ===
|
||||
Stack: <detected stack>
|
||||
Goal: <interpreted goal>
|
||||
Scope: <proposed scope>
|
||||
Metric: <metric name> (<higher/lower> is better)
|
||||
Verify: <verify command>
|
||||
Baseline: <dry run result>
|
||||
|
||||
Ready to run. Confirm or adjust any field, then:
|
||||
|
||||
/autoresearch
|
||||
Goal: <goal>
|
||||
Scope: <scope>
|
||||
Metric: <metric>
|
||||
Verify: <verify command>
|
||||
|
||||
Or, for an unattended run, put these same fields into a Qoder Automation prompt
|
||||
(see "Unattended / overnight mode" in SKILL.md).
|
||||
===
|
||||
```
|
||||
|
||||
If the user says "looks good" or "run it" — start the autoresearch loop
|
||||
immediately without requiring them to retype the command.
|
||||
|
||||
---
|
||||
|
||||
## Web research calibration
|
||||
|
||||
After the dry run, use `WebSearch` to calibrate:
|
||||
|
||||
- For SEO goals: search for `[target keyword]` to see what top results look like.
|
||||
Note any structural patterns (FAQ sections, word count, heading structure)
|
||||
that the current content lacks. Add these as initial hypotheses.
|
||||
|
||||
- For performance goals: search for `[framework] performance benchmarks [year]`
|
||||
to calibrate whether the baseline is already good or has significant headroom.
|
||||
|
||||
- For security goals: search for `[stack] common vulnerabilities [year]`
|
||||
to seed the initial hypothesis pool with known attack vectors.
|
||||
|
||||
This research step happens during plan, not during the loop — so it adds
|
||||
context once without slowing down iterations.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
**Goal is too vague** ("make it better"):
|
||||
Ask one clarifying question: "Better in what way — speed, quality, size,
|
||||
coverage, or something else?" Then proceed.
|
||||
|
||||
**Multiple valid verify commands exist**:
|
||||
Propose the fastest one. Note the slower alternative in a comment.
|
||||
|
||||
**Verify command requires a running server**:
|
||||
Note this in the plan output. Add a `# requires: local server on :3000`
|
||||
comment. Suggest the user start it before running the loop.
|
||||
|
||||
**No matching stack detected**:
|
||||
Ask the user to describe their stack in one sentence, then proceed with
|
||||
a custom verify command.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Results logging
|
||||
|
||||
Specification for `autoresearch-results.tsv` — the per-iteration record
|
||||
of every experiment in a run.
|
||||
|
||||
---
|
||||
|
||||
## File format
|
||||
|
||||
Tab-separated values. Headers on row 1. One row per iteration.
|
||||
|
||||
```
|
||||
iteration\tcommit\tmetric\tdelta\tstatus\tdescription
|
||||
```
|
||||
|
||||
### Field definitions
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `iteration` | integer | 0-indexed. Never resets — if you run multiple sessions, continue from the last number. |
|
||||
| `commit` | string | 7-char git short SHA for kept commits. `-` for discards and crashes. |
|
||||
| `metric` | float | Raw metric value from the Verify command. |
|
||||
| `delta` | float | `metric − previous_best`. Sign convention: positive = improvement (regardless of higher/lower goal). |
|
||||
| `status` | enum | One of: `baseline`, `keep`, `discard`, `crash` |
|
||||
| `description` | string | The hypothesis, one sentence. Include the change type and the expected mechanism. |
|
||||
|
||||
---
|
||||
|
||||
## Example file
|
||||
|
||||
```tsv
|
||||
iteration commit metric delta status description
|
||||
0 - 85.2 0.0 baseline initial measurement — test coverage 85.2%
|
||||
1 a1b2c3d 87.1 +1.9 keep add tests for auth middleware edge cases
|
||||
2 - 86.5 -0.7 discard refactor test helpers (broke 2 existing tests)
|
||||
3 - 0.0 0.0 crash add integration tests (postgres connection failed — fix in iter 4)
|
||||
4 b2c3d4e 88.3 +1.2 keep add tests for error handling in API routes
|
||||
5 - 88.1 -0.2 discard add tests for rate limiter (metric within variance, treated as regression)
|
||||
6 c3d4e5f 89.0 +0.7 keep add boundary value tests for form validators
|
||||
7 d4e5f6g 89.8 +0.8 keep add tests for session expiry edge cases
|
||||
8 - 89.2 -0.6 discard mock external API calls (test isolation but metric regressed)
|
||||
9 e5f6g7h 90.6 +0.8 keep add tests for concurrent request handling
|
||||
10 f6g7h8i 91.1 +0.5 keep add tests for malformed JSON input handling
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Progress summary format
|
||||
|
||||
Print every 10 iterations. Use this exact format:
|
||||
|
||||
```
|
||||
=== Autoresearch progress — iteration <N> ===
|
||||
Goal: <original goal statement>
|
||||
Baseline: <iteration 0 metric>
|
||||
Current best: <best metric so far> (<total delta> from baseline)
|
||||
Keeps: <count> (<keeps/total * 100>%)
|
||||
Discards: <count>
|
||||
Crashes: <count>
|
||||
Top pattern: <the change type that has produced the most total delta>
|
||||
Last 5: <sequence of keep/discard/crash for iterations N-4 through N>
|
||||
Est. to goal: <if goal metric is known, N iterations at current rate>
|
||||
===
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interpreting the log
|
||||
|
||||
### Healthy run signature
|
||||
- Keep rate 40-60%
|
||||
- Delta per keep: consistent small positive gains
|
||||
- No long crash streaks
|
||||
- Discards are evenly distributed (not clustered)
|
||||
|
||||
### Warning signs
|
||||
|
||||
| Pattern | Meaning | Action |
|
||||
|---|---|---|
|
||||
| Keep rate < 20% | Hypothesis quality is poor | Re-read full scope, re-read lessons, change direction |
|
||||
| Keep rate > 80% | Metric may be too easy or Verify too lenient | Tighten the goal |
|
||||
| Long crash streak (5+) | Verify command is fragile or scope is too risky | Fix Verify or narrow scope |
|
||||
| Delta per keep shrinking toward 0 | Approaching local optimum | Try more radical changes or declare victory |
|
||||
| Metric oscillating | Non-deterministic Verify or contradictory changes | Run Verify twice and average; tighten scope |
|
||||
|
||||
### Declaring success
|
||||
|
||||
Stop the loop when one of these is true:
|
||||
- Metric has reached the stated goal
|
||||
- Delta per keep has been below 0.1% for 20 consecutive iterations
|
||||
(local optimum with current scope)
|
||||
- All directions have been exhausted (lessons file confirms this)
|
||||
|
||||
In all cases, print a final summary and write a lessons entry covering
|
||||
the full run before stopping.
|
||||
|
||||
---
|
||||
|
||||
## File hygiene
|
||||
|
||||
- Add `autoresearch-results.tsv` to `.gitignore`. It is a working file.
|
||||
- Do not edit it manually during a run.
|
||||
- Between runs, you may archive it:
|
||||
`mv autoresearch-results.tsv autoresearch-results-<date>.tsv`
|
||||
and start fresh, but keep the lessons file — that is the persistent memory.
|
||||
@@ -0,0 +1,171 @@
|
||||
# Security workflow — `autoresearch security` mode
|
||||
|
||||
Autonomous security audit using STRIDE threat modelling and OWASP categories.
|
||||
Finds vulnerabilities, classifies them by severity, and optionally fixes
|
||||
confirmed critical and high findings via an autoresearch loop.
|
||||
|
||||
---
|
||||
|
||||
## Invocation
|
||||
|
||||
```
|
||||
autoresearch security # full audit, report only
|
||||
autoresearch security --fix # audit + auto-fix confirmed findings
|
||||
autoresearch security --fail-on critical # end with a FAIL verdict if critical found
|
||||
autoresearch security --scope src/api/ # audit a specific directory only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Asset discovery
|
||||
|
||||
Map the attack surface:
|
||||
|
||||
1. Identify all entry points: API routes, form handlers, file uploads,
|
||||
auth flows, webhooks, admin panels
|
||||
2. Identify all data stores: databases, caches, file system writes,
|
||||
environment variables, secrets
|
||||
3. Identify all trust boundaries: public vs authenticated, user vs admin,
|
||||
internal vs external services
|
||||
4. Map data flows: what user input reaches what data store via what path
|
||||
|
||||
Output: `security/audit-<timestamp>/attack-surface-map.md`
|
||||
|
||||
### Live threat intelligence
|
||||
|
||||
Use `WebSearch` to seed the audit with current threats:
|
||||
|
||||
```
|
||||
WebSearch: [your stack] common vulnerabilities [current year]
|
||||
WebSearch: [your main framework] CVE [current year]
|
||||
WebSearch: OWASP top 10 [current year]
|
||||
```
|
||||
|
||||
Add any newly discovered attack patterns to the audit queue.
|
||||
This ensures the audit covers threats that postdate your static analysis tools.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — STRIDE threat model
|
||||
|
||||
For each asset and trust boundary, model threats across all 6 STRIDE categories:
|
||||
|
||||
| Category | Question to ask |
|
||||
|---|---|
|
||||
| **S**poofing | Can an attacker impersonate a user, service, or system? |
|
||||
| **T**ampering | Can input be modified to alter data or behaviour unexpectedly? |
|
||||
| **R**epudiation | Can actions be performed without a traceable audit trail? |
|
||||
| **I**nformation disclosure | Can sensitive data be accessed by unauthorised parties? |
|
||||
| **D**enial of service | Can the service be made unavailable through normal inputs? |
|
||||
| **E**levation of privilege | Can a lower-privilege user gain higher-privilege access? |
|
||||
|
||||
Output: `security/audit-<timestamp>/threat-model.md`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Autonomous audit loop
|
||||
|
||||
```
|
||||
LOOP (through all attack vectors from threat model):
|
||||
1. Select next untested attack vector
|
||||
2. Deep-dive into the relevant code (read fully — do not skim)
|
||||
3. Attempt to construct a concrete exploit scenario
|
||||
4. Validate with code evidence (file:line + exact scenario)
|
||||
5. Classify: severity + OWASP category + STRIDE tag
|
||||
6. Log to security-audit-results.tsv
|
||||
7. Print coverage summary every 5 iterations
|
||||
8. Continue until all vectors tested
|
||||
```
|
||||
|
||||
### Severity classification
|
||||
|
||||
| Severity | Definition |
|
||||
|---|---|
|
||||
| Critical | Exploitable without authentication, leads to full compromise or data breach |
|
||||
| High | Exploitable with low-privilege access, significant impact |
|
||||
| Medium | Requires specific conditions, moderate impact |
|
||||
| Low | Minor information disclosure, no direct exploitation path |
|
||||
| Info | Best practice violation, no immediate security impact |
|
||||
|
||||
### Evidence requirement
|
||||
|
||||
Every finding MUST have:
|
||||
- File path and line number
|
||||
- Exact vulnerable code snippet (copy from source, do not paraphrase)
|
||||
- Concrete exploit scenario (how an attacker would trigger this)
|
||||
- Proof of exploitability (not theoretical — show the actual path)
|
||||
|
||||
Findings without concrete evidence are logged as "unconfirmed" and flagged
|
||||
for manual review, not included in the fix loop.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Report generation
|
||||
|
||||
Output folder: `security/audit-<timestamp>/`
|
||||
|
||||
```
|
||||
security/audit-20260325-1430/
|
||||
├── overview.md ← executive summary + finding counts by severity
|
||||
├── threat-model.md ← STRIDE analysis per asset
|
||||
├── attack-surface-map.md ← entry points, data flows, trust boundaries
|
||||
├── findings.md ← all confirmed findings, sorted by severity
|
||||
├── owasp-coverage.md ← coverage matrix — which OWASP categories checked
|
||||
├── recommendations.md ← fix guidance for each confirmed finding
|
||||
└── security-audit-results.tsv ← machine-readable log of all iterations
|
||||
```
|
||||
|
||||
Print summary:
|
||||
```
|
||||
=== Security audit summary ===
|
||||
Critical: <N>
|
||||
High: <N>
|
||||
Medium: <N>
|
||||
Low: <N>
|
||||
Info: <N>
|
||||
Vectors tested: <N> / <total>
|
||||
OWASP categories covered: <list>
|
||||
|
||||
Full report: security/audit-<timestamp>/overview.md
|
||||
===
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Auto-fix loop (with `--fix`)
|
||||
|
||||
Only runs when `--fix` flag is passed.
|
||||
Only fixes **Confirmed Critical and High** findings.
|
||||
Uses `recommendations.md` as the fix guide for each finding.
|
||||
|
||||
```
|
||||
FOR EACH confirmed Critical/High finding:
|
||||
1. Read the finding + recommendation
|
||||
2. Make ONE targeted fix
|
||||
3. git commit the fix
|
||||
4. Re-run the specific exploit scenario to verify it no longer works
|
||||
5. Run full test suite to confirm no regressions
|
||||
6. If tests break → revert, try alternative fix
|
||||
7. Maximum 3 attempts per finding, then skip and flag for manual review
|
||||
8. Log fix outcome to fix-log.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verdict mode (`--fail-on`)
|
||||
|
||||
```
|
||||
autoresearch security --fail-on critical
|
||||
```
|
||||
|
||||
The audit ends with an explicit verdict line in `overview.md`:
|
||||
|
||||
```
|
||||
VERDICT: FAIL — 2 findings at or above `critical`
|
||||
VERDICT: PASS — no findings at or above `critical`
|
||||
```
|
||||
|
||||
A skill run has no process exit code, so do not wire this into a CI gate as if
|
||||
it did — use a real scanner for blocking merges. What it *is* good for is an
|
||||
unattended scheduled audit: a Qoder Automation running this mode reports the
|
||||
verdict, and you act on it.
|
||||
@@ -0,0 +1,164 @@
|
||||
# Ship workflow — `autoresearch ship`
|
||||
|
||||
Run a pre-flight checklist before shipping — tests, types, lint, bundle size,
|
||||
security basics, and a final autoresearch pass on anything that fails.
|
||||
|
||||
The ship workflow is not just a checklist. It runs an autoresearch loop on
|
||||
each failing gate until it passes, then re-checks. You don't ship broken.
|
||||
You ship when everything is green.
|
||||
|
||||
---
|
||||
|
||||
## Invocation
|
||||
|
||||
```
|
||||
autoresearch ship
|
||||
```
|
||||
|
||||
Optional flags:
|
||||
```
|
||||
autoresearch ship --fast # skip slow checks (lighthouse, e2e)
|
||||
autoresearch ship --loop N # max N autoresearch iterations per gate (default: 20)
|
||||
autoresearch ship --dry-run # report status without fixing anything
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The ship checklist
|
||||
|
||||
The workflow runs these gates in order. Each gate that fails triggers an
|
||||
autoresearch sub-loop to fix it before moving to the next gate.
|
||||
|
||||
### Gate 1 — Tests pass
|
||||
|
||||
```bash
|
||||
npm test # Node.js
|
||||
pytest # Python
|
||||
go test ./... # Go
|
||||
cargo test # Rust
|
||||
```
|
||||
|
||||
If tests fail → autoresearch loop on `src/**/*.ts` (or equivalent) with
|
||||
metric: failing test count (lower is better), max 20 iterations.
|
||||
|
||||
### Gate 2 — No type errors
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit # TypeScript
|
||||
mypy src/ # Python
|
||||
```
|
||||
|
||||
If errors found → autoresearch loop on `src/**/*.ts` with
|
||||
metric: error count (lower is better), max 20 iterations.
|
||||
|
||||
### Gate 3 — No lint errors
|
||||
|
||||
```bash
|
||||
npx eslint src/ # JavaScript/TypeScript
|
||||
ruff check src/ # Python
|
||||
golangci-lint run # Go
|
||||
```
|
||||
|
||||
If errors found → autoresearch loop with metric: lint error count (lower is better).
|
||||
Auto-fixable errors are fixed first (`--fix` flag), then the loop handles the rest.
|
||||
|
||||
### Gate 4 — Bundle size (if applicable)
|
||||
|
||||
Only runs for frontend projects (detected: `next.config.*`, `vite.config.*`,
|
||||
`webpack.config.*`).
|
||||
|
||||
```bash
|
||||
npm run build 2>&1 | grep "First Load JS"
|
||||
```
|
||||
|
||||
Threshold: warn if > 300KB, block if > 500KB (configurable via `.autoresearch.yml`).
|
||||
|
||||
If over threshold → autoresearch loop on `src/**/*.tsx, src/**/*.ts` with
|
||||
metric: bundle size in KB (lower is better), max 20 iterations.
|
||||
|
||||
### Gate 5 — No hardcoded secrets
|
||||
|
||||
```bash
|
||||
git diff HEAD~1 --diff-filter=A | grep -iE "(api_key|secret|password|token)\s*=\s*['\"][^'\"]{8,}"
|
||||
```
|
||||
|
||||
If secrets found → do NOT autoresearch. Flag for human review. Block ship.
|
||||
|
||||
### Gate 6 — Dependency audit
|
||||
|
||||
```bash
|
||||
npm audit --audit-level=high # Node.js
|
||||
pip-audit # Python
|
||||
```
|
||||
|
||||
If critical vulnerabilities found → autoresearch loop to update affected
|
||||
dependencies, max 10 iterations.
|
||||
|
||||
---
|
||||
|
||||
## Ship report
|
||||
|
||||
After all gates pass, print:
|
||||
|
||||
```
|
||||
=== Ship report ===
|
||||
Tests: ✓ PASS (247 passing)
|
||||
Types: ✓ PASS (0 errors)
|
||||
Lint: ✓ PASS (0 errors)
|
||||
Bundle: ✓ PASS (187KB)
|
||||
Secrets: ✓ PASS (none detected)
|
||||
Deps: ✓ PASS (0 high/critical)
|
||||
|
||||
Autoresearch loops run: <N>
|
||||
Total improvements: <M> iterations kept
|
||||
|
||||
Ready to ship. Run: git push && <your deploy command>
|
||||
===
|
||||
```
|
||||
|
||||
If any gate is still failing after the max iterations:
|
||||
|
||||
```
|
||||
=== Ship report ===
|
||||
Tests: ✓ PASS
|
||||
Types: ✗ FAIL (3 errors remaining after 20 iterations)
|
||||
→ manual fix required: src/auth/session.ts:47
|
||||
|
||||
Ship BLOCKED. Fix the above before shipping.
|
||||
===
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Web research post-check
|
||||
|
||||
After all gates pass, use `WebSearch` to check:
|
||||
|
||||
```
|
||||
WebSearch: [your framework] [version] known issues [current year]
|
||||
WebSearch: [your main dependencies] security advisory [current year]
|
||||
```
|
||||
|
||||
If any critical advisories surface that the dependency audit missed,
|
||||
flag them before shipping. This is a final sanity check that goes beyond
|
||||
what local tools can detect.
|
||||
|
||||
---
|
||||
|
||||
## Configuration via `.autoresearch.yml`
|
||||
|
||||
Create this file in your project root to customise ship behaviour:
|
||||
|
||||
```yaml
|
||||
ship:
|
||||
bundle_warn_kb: 300
|
||||
bundle_block_kb: 500
|
||||
max_iterations_per_gate: 20
|
||||
skip_gates:
|
||||
- lighthouse # skip if no local server available
|
||||
extra_gates:
|
||||
- name: "E2E tests"
|
||||
command: "npx playwright test"
|
||||
metric: "failing tests (lower is better)"
|
||||
max_iterations: 10
|
||||
```
|
||||
@@ -0,0 +1,144 @@
|
||||
# Web research patterns
|
||||
|
||||
Qoder exposes a `WebSearch` tool (and `WebFetch` to read a promising result in
|
||||
full). Use them as a verification supplement — not a replacement for the Verify
|
||||
command, but an additional signal when local scripts alone cannot capture
|
||||
quality.
|
||||
|
||||
---
|
||||
|
||||
## When to use WebSearch in the loop
|
||||
|
||||
| Goal type | Use WebSearch for | Example query |
|
||||
|---|---|---|
|
||||
| SEO content | Check competing pages, keyword signals | `[target keyword] filetype:md OR site:*.dev` |
|
||||
| API correctness | Verify endpoint signatures, check for deprecations | `[library] [method] deprecated 2025 OR 2026` |
|
||||
| Dependency versions | Confirm latest stable before updating | `[package name] latest stable version` |
|
||||
| Best practices | Check if your approach matches current consensus | `[pattern] best practice [language] 2026` |
|
||||
| Content accuracy | Ground-truth check generated facts | `[claim] site:official-source.com` |
|
||||
| Bundle/perf baselines | Compare your score to current industry benchmarks | `[framework] bundle size benchmark 2026` |
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1 — SEO content verification
|
||||
|
||||
Use when: optimising blog posts, landing pages, documentation for search.
|
||||
|
||||
After your local score script runs, supplement with:
|
||||
|
||||
```
|
||||
WebSearch: [target keyword] to see what the top 3 results have in common.
|
||||
Note: heading structure, content length, semantic coverage, internal links.
|
||||
If top results consistently have trait X that your content lacks,
|
||||
add "add trait X" as the next hypothesis.
|
||||
```
|
||||
|
||||
This gives you signal that no local readability or keyword-density script can
|
||||
provide — what the search engine is actually rewarding right now.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2 — API currency check
|
||||
|
||||
Use when: refactoring code that calls external libraries or APIs.
|
||||
|
||||
Before committing any API-surface change:
|
||||
|
||||
```
|
||||
WebSearch: [library name] [method name] changelog 2026
|
||||
WebSearch: [library name] [method name] deprecated
|
||||
```
|
||||
|
||||
If search returns deprecation notices or breaking changes, note the current
|
||||
replacement pattern and use that as the hypothesis instead.
|
||||
|
||||
This prevents iterating toward a working-but-deprecated solution that will
|
||||
break on the next library update.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3 — Dependency version check
|
||||
|
||||
Use when: the Verify command suggests a dependency might be outdated, or when
|
||||
optimising for security/bundle size.
|
||||
|
||||
```
|
||||
WebSearch: [package name] npm latest 2026
|
||||
WebSearch: [package name] security advisory
|
||||
```
|
||||
|
||||
Cross-reference against what is in `package.json`, `go.mod`, `requirements.txt`
|
||||
or equivalent. Use the delta as a hypothesis: "update [package] from X to Y,
|
||||
check if metric improves."
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4 — Best practice calibration
|
||||
|
||||
Use when: stuck after 5 consecutive discards and local ideas are exhausted.
|
||||
|
||||
```
|
||||
WebSearch: [language/framework] [metric type] optimisation techniques 2026
|
||||
WebSearch: how to improve [metric] in [stack]
|
||||
```
|
||||
|
||||
Extract 3 concrete, actionable techniques from the top results — use `WebFetch`
|
||||
on the most promising one if the snippet is too thin. Do not extract vague
|
||||
advice. Add each as a separate iteration hypothesis. This restocks your
|
||||
hypothesis pool with externally validated approaches.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 5 — Benchmark calibration
|
||||
|
||||
Use when: you want to know if your current metric value is good relative to
|
||||
the industry, not just relative to your own baseline.
|
||||
|
||||
```
|
||||
WebSearch: [framework] [metric] benchmark 2026 average
|
||||
```
|
||||
|
||||
If your metric is already at or above the industry median, note this and
|
||||
shift the goal definition (e.g. from "reduce bundle size" to "reduce bundle
|
||||
size while improving lighthouse score").
|
||||
|
||||
---
|
||||
|
||||
## Pattern 6 — Content accuracy check
|
||||
|
||||
Use when: the Verify command measures style/structure but not factual accuracy
|
||||
(e.g. documentation, blog posts, runbooks).
|
||||
|
||||
```
|
||||
WebSearch: [specific claim in content] site:[authoritative source]
|
||||
```
|
||||
|
||||
If the authoritative source contradicts your content, flag this as a
|
||||
required fix before the next iteration (accuracy issues override metric gains).
|
||||
|
||||
---
|
||||
|
||||
## Rules for using WebSearch
|
||||
|
||||
1. **Supplement, never replace.** The Verify command runs every iteration.
|
||||
Web research adds signal; it does not replace the metric.
|
||||
|
||||
2. **Search at the right time.** Patterns 1-3 supplement Phase 5 (Verify).
|
||||
Patterns 4-5 are for stuck recovery in Phase 1 (Review). Pattern 6
|
||||
runs in Phase 6 (Decide) when a kept iteration touches factual claims.
|
||||
|
||||
3. **Extract actionable hypotheses.** Never let a search result produce a
|
||||
vague conclusion ("content could be better"). Always turn the search
|
||||
result into a specific next hypothesis ("add a FAQ section with 3
|
||||
questions, which top-ranking competitors include").
|
||||
|
||||
4. **Log the research signal.** When a search result influences a hypothesis,
|
||||
note it in the results log description:
|
||||
`"added FAQ section (web research: top results for [kw] all include FAQ)"`
|
||||
|
||||
5. **Don't over-search.** Maximum one WebSearch call per iteration. If you are
|
||||
searching every iteration, your Verify command is probably too weak —
|
||||
strengthen the local script instead.
|
||||
|
||||
6. **Cite, don't guess.** `WebSearch` results come with source links; never
|
||||
turn an unverified snippet into a change that the Guard cannot catch.
|
||||
@@ -7,7 +7,7 @@ description: "Wavelet 项目专用:当新增或修改 ClickHouse 批量写入
|
||||
|
||||
开始前阅读根目录 `AGENTS.md`。ClickHouse 是辅助 OLAP 存储,**厌恶高频单条写入**(过多小 part);写入路径必须优先批量或异步聚合。
|
||||
|
||||
DDL 与表结构变更见 `database-migration` 技能;本技能只覆盖**运行时写入架构**。
|
||||
DDL 与表结构变更见 `database-migration` 技能。日志/分析用途表的判定、三库回落与切换见 `logstore` 技能。本技能只覆盖**运行时写入架构**。
|
||||
|
||||
## 分层职责
|
||||
|
||||
@@ -17,7 +17,7 @@ DDL 与表结构变更见 `database-migration` 技能;本技能只覆盖**运
|
||||
| 批量框架 | `internal/infra/persistence/batchwriter/` | 泛型队列 + 按条数/时间 flush + 非阻塞入队 + 优雅停机;**各业务域独立实例** |
|
||||
| Model | `internal/model/analytics/` | 列定义、`TableName()`、`BatchInsertSQL()`(及可选 `InsertColumns()`) |
|
||||
| Repository | `internal/repository/analytics/` | `BatchInsert*` / `BatchInsertNodeAccessLogs` 等;`PrepareBatch` + 多行 `Append` + 一次 `Send` |
|
||||
| Apps | `internal/apps/<domain>/` | 采集、入队、背压;`FlushFunc` 只调 repository,不写 SQL、不 `PrepareBatch` |
|
||||
| Apps | `internal/apps/<domain>/` | 采集、入队、背压;`FlushFunc` 只调 logstore / repository,不写 SQL、不 `PrepareBatch` |
|
||||
| 装配 | `internal/platform/bootstrap/bootstrap.go` | 进程启动时调用 `Writer.Start`;初始化时需调用 `lifecycle.OnShutdown` 挂载停机钩子 |
|
||||
| 生命周期 | `internal/platform/lifecycle/lifecycle.go` | 统一协调全局并发优雅停机,业务包无需在 `bootstrap.go` 中硬编码 `Stop` 逻辑 |
|
||||
|
||||
@@ -37,6 +37,7 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
|
||||
|
||||
- `QueueSize`: 10_000
|
||||
- `MaxBatchSize`: 1_000
|
||||
- `MinBatchSize`: 50(未达阈值则跳过按时间 flush,除非设了 `MaxFlushWait`)
|
||||
- `FlushInterval`: 1s
|
||||
|
||||
各域可独立覆盖;可观测低频指标可用更小 `MaxBatchSize`(如 100)与更长 `FlushInterval`(如 2–5s),但**不要**退化为逐条 `Send`。
|
||||
@@ -49,7 +50,8 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
|
||||
### FlushFunc 规范
|
||||
|
||||
- 签名:`func(ctx context.Context, items []T) error`
|
||||
- 内部调用 `internal/repository/analytics` 的 `BatchInsert*`(传入 `[]analyticsmodel.X`)
|
||||
- **日志/分析用途表**:`logstore.Active(ctx)` 再调对应 `BatchInsert*`。禁止 apps 直连 `analyticsrepo` 或 `db.ChConn`。
|
||||
- 仅 CH、无需主库回落的分析表:才直接调 `repository/analytics` 的 `BatchInsert*`。
|
||||
- 在 flush 边界记录一次错误日志,不要把 DB 驱动错误直接暴露给 HTTP 客户端
|
||||
- `Start` 使用 `context.WithoutCancel(parent)`,避免请求 ctx 取消中断后台 flush
|
||||
|
||||
@@ -57,11 +59,11 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
|
||||
|
||||
每个业务域拥有自己的 `Writer`、配置与 `FlushFunc`:
|
||||
|
||||
| 域 | 表 | 现状 | 目标形态 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 管理端审计 | `w_user_access_logs` | `risk_control` → `batchwriter` + `analyticsrepo.BatchInsert` | 已接入 |
|
||||
| 边缘访问日志 | `of_node_access_logs` | `openflare/chwriter` 异步 flush | 已接入 |
|
||||
| 可观测时序 | `of_node_metric_snapshots` 等 5 表 | `openflare/chwriter` 五表独立 writer + 进程内短 TTL 去重 | 已接入 |
|
||||
| 域 | 表 | 写入路径 |
|
||||
| :--- | :--- | :--- |
|
||||
| 管理端审计 | `w_user_access_logs` | `risk_control` → `batchwriter` → `logstore.Active` |
|
||||
| 边缘访问日志 | `of_node_access_logs` | `openflare/chwriter` → `logstore.Active` |
|
||||
| 可观测时序 | `of_node_metric_snapshots` 等 | `openflare/chwriter` 分表 writer + 进程内短 TTL 去重 → `logstore.Active` |
|
||||
|
||||
**不要**把 audit、access log、observability 并入同一 channel。
|
||||
|
||||
@@ -73,8 +75,9 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
|
||||
- `len(items)==0` 直接返回
|
||||
- `db.ChConn == nil` 返回明确错误
|
||||
- 一次 `PrepareBatch` → 循环 `Append` → 一次 `Send`
|
||||
4. **Writer 胶水**(`internal/apps/<domain>/` 或 `internal/repository/analytics/<domain>_writer.go`):
|
||||
4. **Writer 胶水**(`internal/apps/<domain>/`):
|
||||
- `New` + `Start`,并在初始化逻辑内通过 `lifecycle.OnShutdown("your_writer_name", Stop)` 注册停机回调
|
||||
- 日志表的 `FlushFunc` 调 `logstore.Active`(见 `logstore` skill)
|
||||
- 业务路径 `TryEnqueue`;HTTP 背压用 `IsFull()`
|
||||
5. **测试**:
|
||||
- repository:mock `ChConn` 验证 `BatchInsertSQL` 与 append 列数
|
||||
@@ -123,14 +126,9 @@ var globalChan chan any
|
||||
|
||||
```go
|
||||
// internal/platform/bootstrap/bootstrap.go(示意)
|
||||
var userAccessLogWriter *batchwriter.Writer[*analytics.UserAccessLog]
|
||||
|
||||
func RegisterAPI(ctx context.Context) {
|
||||
// ...
|
||||
if config.Config.ClickHouse.Enabled {
|
||||
initUserAccessLogWriter(ctx) // Start writer
|
||||
risk_control.BindWriter(userAccessLogWriter) // 或逐步替换 InitLogWriter
|
||||
}
|
||||
// 日志 writer 不依赖 clickhouse.enabled:flush 时由 logstore 选库
|
||||
risk_control.InitLogWriter(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -149,7 +147,8 @@ make code-check
|
||||
- flush 按 `MaxBatchSize` 与 `FlushInterval` 触发
|
||||
- `Stop` 能 drain 队列内剩余项
|
||||
- repository 层无 goroutine、无 channel
|
||||
- `clickhouse.enabled: false` 时不 `Start` writer、不入队
|
||||
- 日志表:`clickhouse.enabled: false` 时 writer 仍 `Start`,flush 走主库 logstore
|
||||
- 仅 CH 的分析表:未启用 CH 时不要 `Start`、不要入队
|
||||
|
||||
## 相关文件速查
|
||||
|
||||
@@ -157,7 +156,8 @@ make code-check
|
||||
- 连接:`internal/infra/persistence/clickhouse.go`
|
||||
- 审计写入:`internal/apps/risk_control/logics.go`
|
||||
- OpenFlare 写入胶水:`internal/apps/openflare/chwriter/writer.go`
|
||||
- 节点访问日志 repository:`internal/repository/analytics/node_access_log_writer.go`
|
||||
- 可观测 repository:`internal/repository/analytics/node_observability_writer.go`
|
||||
- 日志抽象:`internal/repository/logstore`
|
||||
- 节点访问日志 CH 实现:`internal/repository/analytics/node_access_log_writer.go`
|
||||
- 可观测 CH 实现:`internal/repository/analytics/node_observability_writer.go`
|
||||
- 生命周期管理器:`internal/platform/lifecycle/lifecycle.go`
|
||||
- Bootstrap:`internal/platform/bootstrap/bootstrap.go`
|
||||
@@ -81,7 +81,7 @@ make code-check
|
||||
ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独立**的迁移与访问管线:
|
||||
|
||||
- 主库(PG/SQLite):业务事务数据、`goose_db_version`、双方言 SQL。
|
||||
- 分析库(ClickHouse):访问日志、统计聚合等分析型数据、`goose_clickhouse_version`、单方言 SQL。
|
||||
- 分析库(ClickHouse):分析型数据、`goose_clickhouse_version`、单方言 SQL。日志用途表还必须在主库建回落并走 `logstore`(见该 skill);CH 目录仍只放 CH DDL。
|
||||
|
||||
**不要**把 ClickHouse 表结构混入 PG/SQLite 迁移目录,也**不要**在 `support-files/`、`internal/apps/` 或 `internal/repository/` 中手写 DDL。
|
||||
|
||||
@@ -118,7 +118,7 @@ ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独
|
||||
1. **Model**:在 `internal/model/analytics/` 定义 struct,`gorm:"column:..."` 与 DDL 列名一一对应;实现 `TableName()`,批量写入表可提供 `InsertColumns()` / `BatchInsertSQL()`。
|
||||
2. **Goose SQL**:在 `internal/infra/persistence/migrator/goose/clickhouse/` 新增递增版本文件(格式同主库,如 `YYYYMMDDNNNN_create_xxx.sql`),编写 `-- +goose Up` / `-- +goose Down`。
|
||||
3. **Repository**:在 `internal/repository/analytics/` 实现 `BatchInsert*`(`db.ChConn` 一次 `PrepareBatch` + 多行 `Append` + 一次 `Send`)与查询(`db.ChDB`);连接未初始化时返回明确错误,**不要**在 handler 写 SQL,**不要**在 repository 内维护 channel/goroutine。
|
||||
4. **Apps**:在 `internal/apps/<domain>/` 编排采集与入队;高频写入通过 `internal/infra/persistence/batchwriter` 各域独立实例异步 flush(详见 `clickhouse-batchwriter` 技能),`FlushFunc` 只调 repository `BatchInsert*`;管理端统计 API 只读 repository,不触达 DDL。
|
||||
4. **Apps**:在 `internal/apps/<domain>/` 编排采集与入队;高频写入通过 `internal/infra/persistence/batchwriter` 各域独立实例异步 flush(详见 `clickhouse-batchwriter` 技能)。**日志/分析用途表**还要同时建 PG/SQLite 回落并接入 `logstore`(见 `logstore` 技能),`FlushFunc` 调 `logstore.Active` 而不是 `analyticsrepo`;普通业务分析表仍只读 repository。
|
||||
|
||||
### ClickHouse 验证
|
||||
|
||||
|
||||
@@ -83,8 +83,8 @@ invoice.FilePath = "uploads/2026/01/02/123.pdf"
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"OpenFlare/internal/apps/upload"
|
||||
"OpenFlare/internal/model"
|
||||
)
|
||||
|
||||
func ingestMirrorFile(ctx context.Context, userID uint64, data []byte, hash, filename, mime, ext string) (model.Upload, error) {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
name: go-documentation
|
||||
description: 在编写或审查 Go 包、类型、函数或方法的文档时使用。在创建新的导出类型、函数或包时也应主动使用,即使用户没有明确询问文档问题。不涵盖未导出符号的代码注释(参见 go-style-core)。
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Google 风格指南"
|
||||
allowed-tools: Bash(bash:*)
|
||||
---
|
||||
|
||||
# Go 文档
|
||||
|
||||
## 可用脚本
|
||||
|
||||
- **`scripts/check-docs.sh`** — 报告缺少文档注释的导出函数、类型、方法、常量和包。运行 `bash scripts/check-docs.sh --help` 查看选项。
|
||||
|
||||
> 在为新包或导出类型编写文档注释并需要所有文档约定的完整参考时,请参阅 `assets/doc-template.go`。
|
||||
|
||||
---
|
||||
|
||||
## 文档注释
|
||||
|
||||
> **规范**:所有顶层导出名称必须有文档注释。
|
||||
|
||||
### 基本规则
|
||||
|
||||
1. 以被描述对象的名称开头
|
||||
2. 冠词("a"、"an"、"the")可以放在名称前面
|
||||
3. 使用完整句子(首字母大写,带标点符号)
|
||||
|
||||
```go
|
||||
// A Request represents a request to run a command.
|
||||
type Request struct { ...
|
||||
|
||||
// Encode writes the JSON encoding of req to w.
|
||||
func Encode(w io.Writer, req *Request) { ...
|
||||
```
|
||||
|
||||
行为不明显的未导出类型/函数也应有文档注释。
|
||||
|
||||
> **验证**:添加文档注释后,运行 `bash scripts/check-docs.sh` 验证是否有导出符号缺少文档。修复所有缺失后再继续。
|
||||
|
||||
---
|
||||
|
||||
## 注释语句
|
||||
|
||||
> **规范**:文档注释必须是完整的句子。
|
||||
|
||||
- 首字母大写,以标点符号结尾
|
||||
- 例外:如果含义清晰,可以以小写标识符开头
|
||||
- 结构体字段的行尾注释可以是短语
|
||||
|
||||
---
|
||||
|
||||
## 注释行长度
|
||||
|
||||
> **建议**:目标约 80 列,但不设硬性限制。
|
||||
|
||||
根据标点符号换行。不要拆分长 URL。
|
||||
|
||||
---
|
||||
|
||||
## 结构体文档
|
||||
|
||||
使用段落注释对字段分组。标记可选字段及默认值:
|
||||
|
||||
```go
|
||||
type Options struct {
|
||||
// 通用设置:
|
||||
Name string
|
||||
Group *FooGroup
|
||||
|
||||
// 自定义设置:
|
||||
LargeGroupThreshold int // 可选;默认值:10
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 包注释
|
||||
|
||||
> **规范**:每个包必须有且仅有一个包注释。
|
||||
|
||||
```go
|
||||
// Package math provides basic constants and mathematical functions.
|
||||
package math
|
||||
```
|
||||
|
||||
- 对于 `main` 包,使用二进制名称:`// The seed_generator command ...`
|
||||
- 对于较长的包注释,使用 `doc.go` 文件
|
||||
|
||||
> 在编写包级文档、main 包注释、doc.go 文件或可运行示例时,请阅读 [references/EXAMPLES.md](references/EXAMPLES.md)。
|
||||
|
||||
---
|
||||
|
||||
## 文档编写要点
|
||||
|
||||
> **建议**:记录非显而易见的行为,显而易见的行为无需记录。
|
||||
|
||||
| 主题 | 何时记录... | 何时跳过... |
|
||||
|------|------------|------------|
|
||||
| 参数 | 非显而易见的行为、边界情况 | 只是重复类型签名 |
|
||||
| 上下文 | 行为与标准取消不同 | 标准 `ctx.Err()` 返回 |
|
||||
| 并发 | 线程安全性不明确(例如,看似读取但内部修改) | 只读安全、修改不安全 |
|
||||
| 清理 | 始终记录资源释放要求 | — |
|
||||
| 错误 | 哨兵值、错误类型(使用 `*PathError`) | — |
|
||||
| 命名返回值 | 多个同类型参数、面向操作命名 | 类型本身已足够清晰 |
|
||||
|
||||
关键原则:
|
||||
|
||||
- 上下文取消返回 `ctx.Err()` 是隐含的 — 不要重复说明
|
||||
- 只读操作默认线程安全;修改操作默认不安全 — 不要重复说明
|
||||
- 始终记录清理要求(例如,`Call Stop to release resources`)
|
||||
- 在错误类型文档中使用指针(`*PathError`),以确保 `errors.Is`/`errors.As` 正确使用
|
||||
- 不要仅为启用裸返回而命名返回值 — 清晰性 > 简洁性
|
||||
|
||||
> 在记录参数行为、上下文取消、并发安全性、清理要求、错误返回或函数文档注释中的命名返回参数时,请阅读 [references/CONVENTIONS.md](references/CONVENTIONS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 可运行示例
|
||||
|
||||
> **建议**:在测试文件(`*_test.go`)中提供可运行示例。
|
||||
|
||||
```go
|
||||
func ExampleConfig_WriteTo() {
|
||||
cfg := &Config{Name: "example"}
|
||||
cfg.WriteTo(os.Stdout)
|
||||
// Output:
|
||||
// {"name": "example"}
|
||||
}
|
||||
```
|
||||
|
||||
示例会出现在 Godoc 中,附加到对应的文档元素上。
|
||||
|
||||
> 在编写可运行 Example 函数、选择示例命名约定(Example vs ExampleType_Method)或添加包级 doc.go 文件时,请阅读 [references/EXAMPLES.md](references/EXAMPLES.md)。
|
||||
|
||||
---
|
||||
|
||||
## Godoc 格式化
|
||||
|
||||
> 在格式化 godoc 标题、链接、列表或代码块,使用信号增强来标记弃用通知,或在本地预览文档输出时,请阅读 [references/FORMATTING.md](references/FORMATTING.md)。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 主题 | 关键规则 |
|
||||
|------|---------|
|
||||
| 文档注释 | 以名称开头,使用完整句子 |
|
||||
| 行长度 | 约 80 字符,优先考虑可读性 |
|
||||
| 包注释 | 每个包一个,放在 `package` 声明之前 |
|
||||
| 参数 | 仅记录非显而易见的行为 |
|
||||
| 上下文 | 记录与隐含行为不同的例外情况 |
|
||||
| 并发 | 记录线程安全性不明确的情况 |
|
||||
| 清理 | 始终记录资源释放要求 |
|
||||
| 错误 | 记录哨兵值和类型(注意指针) |
|
||||
| 示例 | 在测试文件中使用可运行示例 |
|
||||
| 格式化 | 空行分隔段落,缩进表示代码 |
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **命名约定**:在为文档注释描述的标识符选择名称时,参见 [go-naming](../go-naming/SKILL.md)
|
||||
- **测试示例**:在编写出现在 godoc 中的可运行 `Example` 测试函数时,参见 [go-testing](../go-testing/SKILL.md)
|
||||
- **Lint 强制执行**:在使用 revive 或其他 linter 强制执行文档注释存在性时,参见 [go-linting](../go-linting/SKILL.md)
|
||||
- **风格原则**:在平衡文档详细程度与清晰简洁时,参见 [go-style-core](../go-style-core/SKILL.md)
|
||||
@@ -0,0 +1,61 @@
|
||||
// Package example demonstrates proper Go documentation conventions.
|
||||
//
|
||||
// This package shows how to write doc comments for packages, types,
|
||||
// functions, methods, and constants following Google Go Style Guide
|
||||
// conventions.
|
||||
//
|
||||
// # Getting Started
|
||||
//
|
||||
// Create a new Widget with [NewWidget]:
|
||||
//
|
||||
// w := example.NewWidget("name")
|
||||
// defer w.Close()
|
||||
package example
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrNotFound is returned when a requested item does not exist.
|
||||
var ErrNotFound = errors.New("example: not found")
|
||||
|
||||
// MaxRetries is the default number of retry attempts.
|
||||
const MaxRetries = 3
|
||||
|
||||
// Widget processes items with configurable options.
|
||||
//
|
||||
// A zero-value Widget is not valid; use [NewWidget] to create one.
|
||||
// Widget is safe for concurrent use.
|
||||
//
|
||||
// # Cleanup
|
||||
//
|
||||
// Call [Widget.Close] when done to release resources.
|
||||
type Widget struct {
|
||||
name string
|
||||
}
|
||||
|
||||
// NewWidget creates a Widget with the given name.
|
||||
//
|
||||
// Name must be non-empty; NewWidget panics otherwise.
|
||||
func NewWidget(name string) *Widget {
|
||||
if name == "" {
|
||||
panic("example: name must be non-empty")
|
||||
}
|
||||
return &Widget{name: name}
|
||||
}
|
||||
|
||||
// Process handles the given input and returns the result.
|
||||
//
|
||||
// Process returns [ErrNotFound] if the input references
|
||||
// a missing item.
|
||||
func (w *Widget) Process(input string) (string, error) {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// Close releases resources held by the Widget.
|
||||
func (w *Widget) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecated: Use [NewWidget] with functional options instead.
|
||||
func NewWidgetLegacy(name string) *Widget {
|
||||
return NewWidget(name)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
# 文档约定参考
|
||||
|
||||
## 参数和配置
|
||||
|
||||
> **建议**:记录容易出错或非显而易见的参数,而非所有参数。
|
||||
|
||||
```go
|
||||
// 不好:重复了显而易见的信息
|
||||
// Sprintf formats according to a format specifier and returns the resulting string.
|
||||
//
|
||||
// format is the format, and data is the interpolation data.
|
||||
func Sprintf(format string, data ...any) string
|
||||
|
||||
// 好:记录了非显而易见的行为
|
||||
// Sprintf formats according to a format specifier and returns the resulting string.
|
||||
//
|
||||
// The provided data is used to interpolate the format string. If the data does
|
||||
// not match the expected format verbs or the amount of data does not satisfy
|
||||
// the format specification, the function will inline warnings about formatting
|
||||
// errors into the output string.
|
||||
func Sprintf(format string, data ...any) string
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 上下文
|
||||
|
||||
> **建议**:不要重复隐含的上下文行为;记录例外情况。
|
||||
|
||||
上下文取消被隐含地认为会中断函数并返回 `ctx.Err()`。不要记录这一点。
|
||||
|
||||
```go
|
||||
// 不好:重复了隐含的行为
|
||||
// Run executes the worker's run loop.
|
||||
//
|
||||
// The method will process work until the context is cancelled.
|
||||
func (Worker) Run(ctx context.Context) error
|
||||
|
||||
// 好:只记录关键信息
|
||||
// Run executes the worker's run loop.
|
||||
func (Worker) Run(ctx context.Context) error
|
||||
```
|
||||
|
||||
**当行为不同时记录:**
|
||||
|
||||
```go
|
||||
// 好:非标准的取消行为
|
||||
// Run executes the worker's run loop.
|
||||
//
|
||||
// If the context is cancelled, Run returns a nil error.
|
||||
func (Worker) Run(ctx context.Context) error
|
||||
|
||||
// 好:特殊的上下文要求
|
||||
// NewReceiver starts receiving messages sent to the specified queue.
|
||||
// The context should not have a deadline.
|
||||
func NewReceiver(ctx context.Context) *Receiver
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 并发
|
||||
|
||||
> **建议**:记录非显而易见的线程安全特性。
|
||||
|
||||
只读操作被认为是安全的;修改操作被认为是不安全的。不要重复说明这一点。
|
||||
|
||||
**何时记录:**
|
||||
|
||||
```go
|
||||
// 不明确的操作(看似只读但内部有修改)
|
||||
// Lookup returns the data associated with the key from the cache.
|
||||
//
|
||||
// This operation is not safe for concurrent use.
|
||||
func (*Cache) Lookup(key string) (data []byte, ok bool)
|
||||
|
||||
// API 提供同步机制
|
||||
// NewFortuneTellerClient returns an *rpc.Client for the FortuneTeller service.
|
||||
// It is safe for simultaneous use by multiple goroutines.
|
||||
func NewFortuneTellerClient(cc *rpc.ClientConn) *FortuneTellerClient
|
||||
|
||||
// 接口有并发要求
|
||||
// A Watcher reports the health of some entity (usually a backend service).
|
||||
//
|
||||
// Watcher methods are safe for simultaneous use by multiple goroutines.
|
||||
type Watcher interface {
|
||||
Watch(changed chan<- bool) (unwatch func())
|
||||
Health() error
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 清理
|
||||
|
||||
> **建议**:始终记录显式清理要求。
|
||||
|
||||
```go
|
||||
// 好:
|
||||
// NewTicker returns a new Ticker containing a channel that will send the
|
||||
// current time on the channel after each tick.
|
||||
//
|
||||
// Call Stop to release the Ticker's associated resources when done.
|
||||
func NewTicker(d Duration) *Ticker
|
||||
|
||||
// 好:展示如何清理
|
||||
// Get issues a GET to the specified URL.
|
||||
//
|
||||
// When err is nil, resp always contains a non-nil resp.Body.
|
||||
// Caller should close resp.Body when done reading from it.
|
||||
//
|
||||
// resp, err := http.Get("http://example.com/")
|
||||
// if err != nil {
|
||||
// // handle error
|
||||
// }
|
||||
// defer resp.Body.Close()
|
||||
// body, err := io.ReadAll(resp.Body)
|
||||
func (c *Client) Get(url string) (resp *Response, err error)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误
|
||||
|
||||
> **建议**:记录重要的错误哨兵值和类型。
|
||||
|
||||
```go
|
||||
// 好:记录哨兵值
|
||||
// Read reads up to len(b) bytes from the File and stores them in b.
|
||||
//
|
||||
// At end of file, Read returns 0, io.EOF.
|
||||
func (*File) Read(b []byte) (n int, err error)
|
||||
|
||||
// 好:记录错误类型(包含指针接收者)
|
||||
// Chdir changes the current working directory to the named directory.
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
func Chdir(dir string) error
|
||||
```
|
||||
|
||||
注意使用 `*PathError`(而非 `PathError`)可以确保 `errors.Is` 和 `errors.As` 的正确使用。
|
||||
|
||||
对于包级别的错误约定,在包注释中记录。
|
||||
|
||||
---
|
||||
|
||||
## 命名返回参数
|
||||
|
||||
> **建议**:在类型本身不够清晰时用于文档说明。
|
||||
|
||||
```go
|
||||
// 好:多个同类型参数
|
||||
func (n *Node) Children() (left, right *Node, err error)
|
||||
|
||||
// 好:面向操作的名称阐明了用法
|
||||
// The caller must arrange for the returned cancel function to be called.
|
||||
func WithTimeout(parent Context, d time.Duration) (ctx Context, cancel func())
|
||||
|
||||
// 不好:类型已经很清晰,命名没有增加信息
|
||||
func (n *Node) Parent1() (node *Node)
|
||||
func (n *Node) Parent2() (node *Node, err error)
|
||||
|
||||
// 好:类型已足够
|
||||
func (n *Node) Parent1() *Node
|
||||
func (n *Node) Parent2() (*Node, error)
|
||||
```
|
||||
|
||||
不要仅为启用裸返回而命名返回值。清晰性 > 简洁性。
|
||||
|
||||
---
|
||||
|
||||
## 弃用通知
|
||||
|
||||
> **建议**:使用 `// Deprecated:` 注释标记符号为已弃用。
|
||||
|
||||
`Deprecated:` 段落必须出现在文档注释中紧接在符号之前。应说明使用什么替代。
|
||||
|
||||
**标准格式:**
|
||||
|
||||
```
|
||||
// Deprecated: Use NewThing instead.
|
||||
```
|
||||
|
||||
Godoc 会以特殊的视觉样式渲染 `Deprecated:` 注释,使其容易被发现。
|
||||
|
||||
**函数弃用:**
|
||||
|
||||
```go
|
||||
// EstimateSize returns an approximate byte count.
|
||||
//
|
||||
// Deprecated: Use [Size] instead, which returns an exact count.
|
||||
func EstimateSize(r io.Reader) (int64, error)
|
||||
```
|
||||
|
||||
**类型弃用:**
|
||||
|
||||
```go
|
||||
// LegacyClient talks to the v1 API.
|
||||
//
|
||||
// Deprecated: Use [Client] instead, which supports v2.
|
||||
type LegacyClient struct{ /* ... */ }
|
||||
```
|
||||
|
||||
**包弃用** — 在包文档注释中添加 `Deprecated:`:
|
||||
|
||||
```go
|
||||
// Package old provides the original implementation.
|
||||
//
|
||||
// Deprecated: Use package example/new instead.
|
||||
package old
|
||||
```
|
||||
|
||||
始终建议具体的替代方案,让调用者知道迁移目标。
|
||||
|
||||
---
|
||||
|
||||
## 注释语句 — 详细说明
|
||||
|
||||
> **规范**:文档注释必须是完整的句子。
|
||||
|
||||
- 首字母大写,以标点符号结尾
|
||||
- 例外:如果含义清晰,可以以小写标识符开头
|
||||
- 结构体字段的行尾注释可以是短语:
|
||||
|
||||
```go
|
||||
// 好:
|
||||
// A Server handles serving quotes from Shakespeare.
|
||||
type Server struct {
|
||||
// BaseDir points to the base directory for Shakespeare's works.
|
||||
//
|
||||
// Expected structure:
|
||||
// {BaseDir}/manifest.json
|
||||
// {BaseDir}/{name}/{name}-part{number}.txt
|
||||
BaseDir string
|
||||
|
||||
WelcomeMessage string // 用户登录时显示
|
||||
ProtocolVersion string // 与传入请求进行校验
|
||||
PageLength int // 每页行数(可选;默认值:20)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,107 @@
|
||||
# 包注释和示例参考
|
||||
|
||||
## 包注释
|
||||
|
||||
> **规范**:每个包必须有且仅有一个包注释。
|
||||
|
||||
```go
|
||||
// 好:
|
||||
// Package math provides basic constants and mathematical functions.
|
||||
//
|
||||
// This package does not guarantee bit-identical results across architectures.
|
||||
package math
|
||||
```
|
||||
|
||||
### Main 包
|
||||
|
||||
使用二进制名称(与 BUILD 文件匹配):
|
||||
|
||||
```go
|
||||
// 好:
|
||||
// The seed_generator command is a utility that generates a Finch seed file
|
||||
// from a set of JSON study configs.
|
||||
package main
|
||||
```
|
||||
|
||||
有效格式:`Binary seed_generator`、`Command seed_generator`、`The seed_generator command`、`Seed_generator ...`
|
||||
|
||||
### doc.go
|
||||
|
||||
- 对于较长的包注释,使用仅包含包注释和 `package` 声明的 `doc.go` 文件
|
||||
- 放在 import 之后的维护者注释不会出现在 Godoc 中
|
||||
- 保持 doc.go 文件专注于面向用户的文档
|
||||
|
||||
```go
|
||||
// Package complex provides advanced mathematical operations for
|
||||
// complex number arithmetic, including polar form conversion,
|
||||
// matrix operations, and numerical integration.
|
||||
//
|
||||
// Basic usage
|
||||
//
|
||||
// Create a complex number and perform operations:
|
||||
//
|
||||
// z := complex.New(3, 4)
|
||||
// magnitude := z.Abs() // 5.0
|
||||
// conjugate := z.Conj() // (3, -4)
|
||||
//
|
||||
// Matrix operations
|
||||
//
|
||||
// The package supports complex-valued matrices:
|
||||
//
|
||||
// m := complex.NewMatrix(2, 2)
|
||||
// m.Set(0, 0, complex.New(1, 0))
|
||||
// det := m.Det()
|
||||
package complex
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 可运行示例
|
||||
|
||||
> **建议**:提供可运行示例来展示包的用法。
|
||||
|
||||
将示例放在测试文件(`*_test.go`)中:
|
||||
|
||||
```go
|
||||
// 好:
|
||||
func ExampleConfig_WriteTo() {
|
||||
cfg := &Config{
|
||||
Name: "example",
|
||||
}
|
||||
if err := cfg.WriteTo(os.Stdout); err != nil {
|
||||
log.Exitf("Failed to write config: %s", err)
|
||||
}
|
||||
// Output:
|
||||
// {
|
||||
// "name": "example"
|
||||
// }
|
||||
}
|
||||
```
|
||||
|
||||
示例会出现在 Godoc 中,附加到对应的文档元素上。
|
||||
|
||||
### 命名约定
|
||||
|
||||
| 函数名称 | 文档对象 |
|
||||
|----------|---------|
|
||||
| `Example()` | 包级别示例 |
|
||||
| `ExampleFoo()` | 函数 `Foo` |
|
||||
| `ExampleBar_Baz()` | 方法 `Bar.Baz` |
|
||||
| `ExampleFoo_suffix()` | `Foo` 示例的命名变体 |
|
||||
|
||||
### 技巧
|
||||
|
||||
- 使用 `// Output:` 注释使示例可通过 `go test` 进行测试和验证
|
||||
- 保持示例专注于展示一个概念
|
||||
- 使用真实但精简的数据
|
||||
- 对于复杂的设置,使用 `testMain` 或辅助函数保持示例主体简洁
|
||||
- 同一符号的多个示例使用小写 `_suffix`:
|
||||
|
||||
```go
|
||||
func ExampleNewClient_withTimeout() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
client := NewClient(ctx)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,85 @@
|
||||
# Godoc 格式化参考
|
||||
|
||||
## Godoc 格式化
|
||||
|
||||
> **建议**:使用 godoc 语法编写格式良好的文档。
|
||||
|
||||
**段落** - 用空行分隔:
|
||||
|
||||
```go
|
||||
// 好:
|
||||
// LoadConfig reads a configuration out of the named file.
|
||||
//
|
||||
// See some/shortlink for config file format details.
|
||||
```
|
||||
|
||||
**逐字/代码块** - 额外缩进两个空格:
|
||||
|
||||
```go
|
||||
// 好:
|
||||
// Update runs the function in an atomic transaction.
|
||||
//
|
||||
// This is typically used with an anonymous TransactionFunc:
|
||||
//
|
||||
// if err := db.Update(func(state *State) { state.Foo = bar }); err != nil {
|
||||
// //...
|
||||
// }
|
||||
```
|
||||
|
||||
**列表和表格** - 使用逐字格式:
|
||||
|
||||
```go
|
||||
// 好:
|
||||
// LoadConfig treats the following keys in special ways:
|
||||
// "import" will make this configuration inherit from the named file.
|
||||
// "env" if present will be populated with the system environment.
|
||||
```
|
||||
|
||||
**标题** - 单行,首字母大写,无标点(括号/逗号除外),后跟段落:
|
||||
|
||||
```go
|
||||
// 好:
|
||||
// Using headings
|
||||
//
|
||||
// Headings come with autogenerated anchor tags for easy linking.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 信号增强
|
||||
|
||||
> **建议**:添加注释以突出不寻常或容易被忽略的模式。
|
||||
|
||||
以下两种情况很难区分:
|
||||
|
||||
```go
|
||||
if err := doSomething(); err != nil { // 常见
|
||||
// ...
|
||||
}
|
||||
|
||||
if err := doSomething(); err == nil { // 不寻常!
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
添加注释来增强信号:
|
||||
|
||||
```go
|
||||
// 好:
|
||||
if err := doSomething(); err == nil { // 如果没有错误
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文档预览
|
||||
|
||||
> **建议**:在代码审查之前和期间预览文档。
|
||||
|
||||
```bash
|
||||
go install golang.org/x/pkgsite/cmd/pkgsite@latest
|
||||
pkgsite
|
||||
```
|
||||
|
||||
这可以验证 godoc 格式化是否正确渲染。
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="1.0.0"
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$SCRIPT_NAME v$VERSION — Check for missing doc comments on exported Go symbols
|
||||
|
||||
USAGE
|
||||
bash $SCRIPT_NAME [options] [path]
|
||||
|
||||
DESCRIPTION
|
||||
Scans Go source files for exported functions, types, methods, constants,
|
||||
and variables that lack doc comments. Go convention requires all exported
|
||||
symbols to have a doc comment starting with the symbol name.
|
||||
|
||||
Exits 0 if all exports are documented, 1 if undocumented exports found,
|
||||
2 on error.
|
||||
|
||||
OPTIONS
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version
|
||||
--json Output results as JSON
|
||||
--strict Also check unexported types/functions with 5+ lines
|
||||
--limit N Show at most N results (default: all)
|
||||
|
||||
ARGUMENTS
|
||||
path Directory or file to check (default: ./...)
|
||||
|
||||
EXAMPLES
|
||||
bash $SCRIPT_NAME
|
||||
bash $SCRIPT_NAME ./pkg/api
|
||||
bash $SCRIPT_NAME --json .
|
||||
bash $SCRIPT_NAME --strict ./internal/server
|
||||
EOF
|
||||
}
|
||||
|
||||
JSON_OUTPUT=false
|
||||
STRICT=false
|
||||
LIMIT=0
|
||||
TARGET=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
|
||||
--json) JSON_OUTPUT=true; shift ;;
|
||||
--strict) STRICT=true; shift ;;
|
||||
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) TARGET="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
TARGET="${TARGET:-./...}"
|
||||
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
find_go_files() {
|
||||
local t="$1"
|
||||
if [[ -f "$t" ]]; then
|
||||
echo "$t"
|
||||
elif [[ -d "$t" ]]; then
|
||||
find "$t" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
|
||||
else
|
||||
local dir="${t%%/...}"
|
||||
dir="${dir:-.}"
|
||||
if [[ -d "$dir" ]]; then
|
||||
find "$dir" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
|
||||
else
|
||||
echo "error: path not found: $t" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
MISSING=()
|
||||
|
||||
add_missing() {
|
||||
local file="$1" line="$2" kind="$3" name="$4"
|
||||
MISSING+=("${file}:${line}|${kind}|${name}")
|
||||
}
|
||||
|
||||
check_file() {
|
||||
local file="$1"
|
||||
local prev_line=""
|
||||
local prev_prev_line=""
|
||||
local line_num=0
|
||||
|
||||
local in_grouped_block=false
|
||||
local grouped_kind=""
|
||||
|
||||
local re_method='^func[[:space:]]+\([^)]+\)[[:space:]]+([A-Z][a-zA-Z0-9]*)\('
|
||||
local re_func='^func[[:space:]]+([A-Z][a-zA-Z0-9]*)\('
|
||||
local re_unexported_func='^func[[:space:]]+([a-z][a-zA-Z0-9]*)\('
|
||||
local re_grouped_open='^(const|var|type)[[:space:]]*\($'
|
||||
local re_exported_type='^type[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]'
|
||||
local re_unexported_type='^type[[:space:]]+([a-z][a-zA-Z0-9]*)[[:space:]]'
|
||||
local re_exported_const='^const[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]'
|
||||
local re_exported_var='^var[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]'
|
||||
local re_grouped_exported='^[[:space:]]+([A-Z][a-zA-Z0-9]*)'
|
||||
local re_grouped_unexported='^[[:space:]]+([a-z][a-zA-Z0-9]*)'
|
||||
|
||||
while IFS= read -r line; do
|
||||
line_num=$((line_num + 1))
|
||||
|
||||
# Check exported function/method declarations
|
||||
if [[ "$line" =~ ^func[[:space:]] ]]; then
|
||||
local name=""
|
||||
local kind=""
|
||||
# Method: func (r *Type) Name(
|
||||
if [[ "$line" =~ $re_method ]]; then
|
||||
name="${BASH_REMATCH[1]}"
|
||||
kind="method"
|
||||
# Function: func Name(
|
||||
elif [[ "$line" =~ $re_func ]]; then
|
||||
name="${BASH_REMATCH[1]}"
|
||||
kind="function"
|
||||
fi
|
||||
|
||||
if [[ -n "$name" ]]; then
|
||||
if ! is_documented "$prev_line" "$prev_prev_line"; then
|
||||
add_missing "$file" "$line_num" "$kind" "$name"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Strict mode: also check unexported functions
|
||||
if $STRICT && [[ -z "$name" ]] && [[ "$line" =~ $re_unexported_func ]]; then
|
||||
name="${BASH_REMATCH[1]}"
|
||||
if ! is_documented "$prev_line" "$prev_prev_line"; then
|
||||
add_missing "$file" "$line_num" "function" "$name"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check exported type declarations
|
||||
if [[ "$line" =~ $re_exported_type ]]; then
|
||||
local name="${BASH_REMATCH[1]}"
|
||||
if ! is_documented "$prev_line" "$prev_prev_line"; then
|
||||
add_missing "$file" "$line_num" "type" "$name"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Strict mode: also check unexported type declarations
|
||||
if $STRICT && [[ "$line" =~ $re_unexported_type ]]; then
|
||||
local name="${BASH_REMATCH[1]}"
|
||||
if ! is_documented "$prev_line" "$prev_prev_line"; then
|
||||
add_missing "$file" "$line_num" "type" "$name"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check exported const (single-line, not in block)
|
||||
if [[ "$line" =~ $re_exported_const ]]; then
|
||||
local name="${BASH_REMATCH[1]}"
|
||||
if ! is_documented "$prev_line" "$prev_prev_line"; then
|
||||
add_missing "$file" "$line_num" "const" "$name"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check exported var (single-line, not blank identifier)
|
||||
if [[ "$line" =~ $re_exported_var ]]; then
|
||||
local name="${BASH_REMATCH[1]}"
|
||||
if ! is_documented "$prev_line" "$prev_prev_line"; then
|
||||
add_missing "$file" "$line_num" "var" "$name"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check package comment
|
||||
if [[ "$line" =~ ^package[[:space:]]+ ]]; then
|
||||
if ! is_documented "$prev_line" "$prev_prev_line"; then
|
||||
local pkg_name
|
||||
pkg_name=$(echo "$line" | sed 's/^package[[:space:]]*//;s/[[:space:]]*$//')
|
||||
add_missing "$file" "$line_num" "package" "$pkg_name"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Track grouped declaration blocks: const ( ... ), var ( ... ), type ( ... )
|
||||
if [[ "$line" =~ $re_grouped_open ]]; then
|
||||
in_grouped_block=true
|
||||
grouped_kind="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
if $in_grouped_block && [[ "$line" =~ ^\)[[:space:]]*$ ]]; then
|
||||
in_grouped_block=false
|
||||
grouped_kind=""
|
||||
fi
|
||||
if $in_grouped_block && [[ -n "$grouped_kind" ]]; then
|
||||
# Check for exported names inside grouped block
|
||||
if [[ "$line" =~ $re_grouped_exported ]]; then
|
||||
local gname="${BASH_REMATCH[1]}"
|
||||
if ! is_documented "$prev_line" "$prev_prev_line"; then
|
||||
add_missing "$file" "$line_num" "$grouped_kind" "$gname"
|
||||
fi
|
||||
fi
|
||||
# Strict: also check unexported names in grouped blocks
|
||||
if $STRICT && [[ "$line" =~ $re_grouped_unexported ]]; then
|
||||
local gname="${BASH_REMATCH[1]}"
|
||||
if ! is_documented "$prev_line" "$prev_prev_line"; then
|
||||
add_missing "$file" "$line_num" "$grouped_kind" "$gname"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
prev_prev_line="$prev_line"
|
||||
prev_line="$line"
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
is_documented() {
|
||||
local prev="$1"
|
||||
local prev_prev="$2"
|
||||
# Previous line is a comment (// or end of block comment */)
|
||||
if [[ "$prev" =~ ^[[:space:]]*//.* ]] || [[ "$prev" =~ \*/[[:space:]]*$ ]]; then
|
||||
return 0
|
||||
fi
|
||||
# Previous line might be empty but line before is comment (allow one blank line)
|
||||
if [[ -z "${prev// /}" ]] && [[ "$prev_prev" =~ ^[[:space:]]*//.* ]]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
FILES=()
|
||||
while IFS= read -r f; do
|
||||
[[ -n "$f" ]] && FILES+=("$f")
|
||||
done < <(find_go_files "$TARGET")
|
||||
|
||||
if [[ ${#FILES[@]} -eq 0 ]]; then
|
||||
if $JSON_OUTPUT; then
|
||||
echo '{"missing":[],"count":0,"status":"no_go_files"}'
|
||||
else
|
||||
echo "No Go files found in: $TARGET"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for file in "${FILES[@]}"; do
|
||||
check_file "$file"
|
||||
done
|
||||
|
||||
# Truncation
|
||||
TOTAL=${#MISSING[@]}
|
||||
TRUNCATED=false
|
||||
if [[ $LIMIT -gt 0 && $TOTAL -gt $LIMIT ]]; then
|
||||
MISSING=("${MISSING[@]:0:$LIMIT}")
|
||||
TRUNCATED=true
|
||||
fi
|
||||
|
||||
if $JSON_OUTPUT; then
|
||||
echo "{"
|
||||
echo ' "missing": ['
|
||||
first=true
|
||||
for entry in "${MISSING[@]+"${MISSING[@]}"}"; do
|
||||
IFS='|' read -r location kind name <<< "$entry"
|
||||
file="${location%%:*}"
|
||||
line="${location#*:}"
|
||||
$first || echo ","
|
||||
first=false
|
||||
printf ' {"file":"%s","line":%s,"kind":"%s","name":"%s"}' \
|
||||
"$(json_escape "$file")" "$line" "$(json_escape "$kind")" "$(json_escape "$name")"
|
||||
done
|
||||
echo ""
|
||||
echo " ],"
|
||||
printf ' "total": %d,\n' "$TOTAL"
|
||||
printf ' "truncated": %s\n' "$TRUNCATED"
|
||||
echo "}"
|
||||
else
|
||||
if [[ $TOTAL -eq 0 ]]; then
|
||||
echo "All exported symbols are documented."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Undocumented exported symbols:"
|
||||
echo ""
|
||||
for entry in "${MISSING[@]}"; do
|
||||
IFS='|' read -r location kind name <<< "$entry"
|
||||
printf " %s [%s] %s\n" "$location" "$kind" "$name"
|
||||
done
|
||||
if $TRUNCATED; then
|
||||
echo " ... and $((TOTAL - LIMIT)) more (use --limit to adjust)"
|
||||
fi
|
||||
echo ""
|
||||
echo "Total: $TOTAL undocumented symbol(s)"
|
||||
fi
|
||||
|
||||
if [[ $TOTAL -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: "logstore"
|
||||
description: "OpenFlare / Wavelet:当新增或修改日志/分析用途表(节点访问日志、用户访问日志、可观测时序)、接入 internal/repository/logstore、切换日志主库、实现 PG/SQLite 回落,或判断一张表该走业务主库还是日志库时必须使用。"
|
||||
---
|
||||
|
||||
# 日志用途表开发
|
||||
|
||||
开始前阅读根目录 `AGENTS.md`。DDL 用 `database-migration`;高频写入队列用 `clickhouse-batchwriter`;切换任务用 `new-async-task`。本技能只回答:**这张表是不是日志表,以及如何接入可切换的日志主库。**
|
||||
|
||||
设计背景见 [日志存储解耦](../../../docs/design/logstore.md)。
|
||||
|
||||
## 先判定
|
||||
|
||||
日志表同时满足:
|
||||
|
||||
- 追加写入、几乎不更新单行
|
||||
- 按时间查询/聚合,允许按保留天数删除
|
||||
- 关闭 ClickHouse 后仍要能写、能查
|
||||
- 不参与网站/节点/证书等事务一致性
|
||||
|
||||
**不要**做成日志表:Zone、节点、配置版本、任务执行、上传元数据。这些走主库 `repository`。
|
||||
|
||||
当前日志域:
|
||||
|
||||
| 域 | 接口 | 表 |
|
||||
| :--- | :--- | :--- |
|
||||
| 节点访问日志 | `AccessLogStore` | `of_node_access_logs` |
|
||||
| 可观测 | `ObservabilityStore` | `of_node_metric_snapshots` / `of_node_edge_health` / `of_node_obs_frps` / `of_node_obs_frpc` |
|
||||
| 用户访问审计 | `UserAccessLogStore` | `w_user_access_logs` |
|
||||
|
||||
## 分层
|
||||
|
||||
| 层级 | 路径 | 职责 |
|
||||
| :--- | :--- | :--- |
|
||||
| 抽象 | `internal/repository/logstore` | 接口 + `Active`/`BuildForMigration`;apps **只**面向这里或 `repository` 门面 |
|
||||
| CH 实现 | `logstore/clickhouse_store.go` 委托 `analytics` | 原生批量 + 现有聚合 SQL |
|
||||
| 主库实现 | `logstore/postgres_store.go` | PG(按月分区)与 SQLite(普通表)共用 GORM |
|
||||
| Model | `internal/model/analytics` | 实体与批量 SQL,无 IO |
|
||||
| 入队 | `chwriter` / `risk_control` + `batchwriter` | flush 调 logstore `BatchInsert*`;CH 入队经 hooks |
|
||||
| 切换 | `of_log_db_switch` | 冻结 → `chwriter.Drain` → 逐表复制 → 翻转 |
|
||||
| 约束 | `logstore/imports_test.go` | apps 禁止 import `repository/analytics` |
|
||||
|
||||
`log_database` 只能是「随主库」或 `clickhouse`。`log_database` / `log_db_migration` 受保护。
|
||||
|
||||
## 新增一张日志表
|
||||
|
||||
1. **Model**(`internal/model/analytics`):`TableName` + `InsertColumns` / `BatchInsertSQL`。
|
||||
2. **三套 DDL**:CH `MergeTree` + `toYYYYMM`;PG `PARTITION BY RANGE(时间列)`(主键含分区键);SQLite 普通表。不要在主库建 CH 物化视图,聚合实时算。
|
||||
3. **挂到已有域或新接口**:能进 `AccessLogStore` / `ObservabilityStore` / `UserAccessLogStore` 就不要再拆包。新域才新增接口并放进 `Store`。
|
||||
4. **方法最少集**:`BatchInsert`(含 `ensureWritable`)、业务查询、`ListForMigration`、`MigrationRange`、`DeleteAll`、`DeleteBefore`、`EnsurePartitions`(仅 PG 预建)。
|
||||
5. **双实现**:CH 委托 `analyticsrepo`;GORM 共用一套,方言 SQL 放 `dialect_*.go`。零值 id 用 `idgen.NextUint64ID()`。
|
||||
6. **`buildStore`**:CH / GORM 两分支都挂上。
|
||||
7. **写入**:独立 `batchwriter`;`FlushFunc` → `logstore.Active`。节点日志/可观测走 `SetAccessLogHooks` / `SetObservabilityHooks`,不要让 apps 碰 `ChConn`。
|
||||
8. **切换任务**:`clearTarget` + `copy*` 增加该表;源数据不删,失败不翻转。
|
||||
9. **清理**:访问类走 `log_retention_days_*`;性能指标走 `metric_retention_days`。不要擅自共用错误的 TTL。
|
||||
10. **import-lint**:apps 新增对 `analytics` 或 `infra/persistence`(`batchwriter`/`idgen` 除外)的 import 必须失败。
|
||||
|
||||
## 禁止
|
||||
|
||||
- apps 直连 `analyticsrepo` / `db.ChConn` / `db.ChDB` 做日志读写
|
||||
- 只建 CH、不建主库回落
|
||||
- Handler 内逐条 `PrepareBatch`
|
||||
- 业务表塞进 logstore
|
||||
- 管理端改 `log_database` / `log_db_migration`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
go test ./internal/repository/logstore ./internal/repository/analytics
|
||||
go test ./internal/apps/openflare/... ./internal/apps/admin/logs ./internal/apps/admin/status
|
||||
make swagger
|
||||
make code-check
|
||||
```
|
||||
|
||||
对照:`of_node_access_logs` 或 `w_user_access_logs` 的 model、三库 goose、`logstore` 双实现、`chwriter`/`risk_control` flush、`LogDBSwitchHandler`。
|
||||
@@ -13,102 +13,38 @@ description: "Wavelet 项目专用:当新增或修改自定义业务 API、新
|
||||
|
||||
Wavelet 后端路由采用了**严格的框架层与业务层隔离机制**。请牢记以下开发原则:
|
||||
|
||||
1. **禁止修改框架级路由文件**:
|
||||
- 以下文件属于系统框架/平台级接口,**禁止为了添加自定义业务接口而进行任何修改**:
|
||||
- `internal/router/router.go`(核心入口委派)
|
||||
- `internal/router/root/default.go`(公开文件服务、robots.txt、Swagger 及 /api/health 路由)
|
||||
- `internal/router/root/frontend.go`(前端静态服务)
|
||||
- `internal/router/v1/v1.go`(V1 分发层协调器)
|
||||
- `internal/router/v1/admin.go`(框架管理员端管理接口)
|
||||
- `internal/router/v1/user.go`(框架普通用户端基础接口、OAuth及公开接口)
|
||||
2. **仅允许在 `custom.go` 中注册业务接口**:
|
||||
- 所有的自定义/业务相关接口注册,有且仅有以下两个合法的承载点:
|
||||
- [internal/router/root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go)(用于挂载到根路径的特殊业务接口)
|
||||
- [internal/router/v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go)(用于挂载在 API V1 下的标准自定义业务接口)
|
||||
### 插件目录标准结构 (`backend/openflare/plugins/<name>/` 或 `backend/plugins/domain/<name>/`)
|
||||
|
||||
---
|
||||
|
||||
## 路由归属判定表 (Where should I register my new API?)
|
||||
|
||||
根据接口的**访问路径特征**和**访问身份/限制条件**,决定将新开发的 API 挂载至何处:
|
||||
|
||||
| 目标 API 路径特征 | 访问身份/条件限制 | 对应的路由注册入口 | 是否允许修改 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **`/my-custom-path`** (挂载在根路径下的特殊业务接口) | 自定义控制 | `root/custom.go` 中的 `RegisterCustomRootRoutes` | **允许修改 (业务自定义入口)** |
|
||||
| **`/api/v1/custom/...`** (API v1 下的定制业务接口) | 自定义控制 | `v1/custom.go` 中的 `RegisterCustomRoutes` | **允许修改 (业务自定义入口)** |
|
||||
| **`/api/v1/admin/...`** (系统管理员管理端接口) | 需要管理员登录 (`admin.LoginAdminRequired()`) | `v1/admin.go` | **禁止修改 (仅限系统框架路由)** |
|
||||
| **`/api/v1/user/...`** (框架普通用户基础接口) | 需要普通用户登录 (`oauth.LoginRequired()`) | `v1/user.go` | **禁止修改 (仅限系统框架路由)** |
|
||||
| **`/api/v1/public/...`** (Captcha、Config 等系统公开接口) | 所有人 (无条件 / 公开) | `v1/user.go` | **禁止修改 (仅限系统框架路由)** |
|
||||
| **`GET /f/:id`**, **`GET /robots.txt`**, **`GET /api/health`** (系统级默认及公开接口) | 所有人 (无条件 / 公开) | `root/default.go` | **禁止修改 (仅限系统框架路由)** |
|
||||
|
||||
---
|
||||
|
||||
## 两个自定义路由包的用法与区别 (Root Custom vs V1 Custom)
|
||||
|
||||
### 1. 根路径自定义包:`root/custom.go`
|
||||
|
||||
* **适用场景**:适用于需要**直接挂载在主域名根路径下**的特殊自定义业务接口(如第三方 Webhook 回调、特定的短链接重定向、外部数据接口等,不需要 `/api/v1` 前缀)。
|
||||
* **用法示例**:
|
||||
在 [root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go) 中实现:
|
||||
```go
|
||||
package root
|
||||
|
||||
import (
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/custom"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterCustomRootRoutes registers custom business routes that belong to the root path.
|
||||
func RegisterCustomRootRoutes(r *gin.Engine) {
|
||||
// 挂载到根路径下,如 GET /my-custom-webhook
|
||||
r.GET("/my-custom-webhook", custom.HandleRootWebhook)
|
||||
}
|
||||
```
|
||||
*(注:该函数已由 `root.go` 自动加载,你无需修改任何其他核心文件。)*
|
||||
|
||||
### 2. V1 API 自定义包:`v1/custom.go`
|
||||
|
||||
* **适用场景**:适用于普通的**自定义业务 API**,需要规范挂载在标准 API V1 路径下(即自动带有 `/api/v1/custom/...` 前缀,可选择性配置用户/管理员登录中间件)。
|
||||
* **用法示例**:
|
||||
在 [v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go) 中实现:
|
||||
```go
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/custom"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterCustomRoutes registers standard custom API routes under /api/v1.
|
||||
func RegisterCustomRoutes(apiV1Router *gin.RouterGroup) {
|
||||
customRouter := apiV1Router.Group("/custom")
|
||||
{
|
||||
// 挂载到 /api/v1/custom 下,例如:POST /api/v1/custom/action
|
||||
customRouter.POST("/action", custom.DoActionHandler)
|
||||
}
|
||||
}
|
||||
```
|
||||
*(注:该函数已由 `v1/v1.go` 自动加载,你无需修改任何其他核心文件。)*
|
||||
|
||||
---
|
||||
|
||||
## 建议创建/修改的文件结构 (Recommended Directory Structure)
|
||||
|
||||
当新增一套定制的业务接口(例如名为 `custom` 的业务模块)时,建议采用以下标准文件结构:
|
||||
所有标准插件与下游定制插件,**统一以 `backend/downstream/plugins/custom_example` 为基准模板**,严格采用物理子包隔离的分层架构:
|
||||
|
||||
```text
|
||||
internal/
|
||||
├── router/
|
||||
│ ├── root/
|
||||
│ │ └── custom.go # [修改] 若为根路径 API,在此处注册,将路由委派给 apps/custom
|
||||
│ └── v1/
|
||||
│ └── custom.go # [修改] 若为 v1 API,在此处注册,将路由委派给 apps/custom
|
||||
└── apps/
|
||||
└── custom/
|
||||
├── routers.go # [新建] HTTP Handlers (Gin),负责参数绑定、校验与响应
|
||||
├── logics.go # [新建] 业务逻辑层:承载模块内闭环的纯 Go 业务逻辑,不依赖 gin.Context
|
||||
└── errs.go # [新建] 存放模块特有的业务错误常量定义(可选)
|
||||
backend/openflare/plugins/<name>/ (或 backend/plugins/domain/<name>/)
|
||||
├── plugin.go # 插件根入口:实现 core.Plugin,装配各子包并向 Cordis 注册
|
||||
│
|
||||
├── consts/ # package consts:常量、配置键名与错误码定义
|
||||
│ └── consts.go
|
||||
│
|
||||
├── controller/ # package controller:HTTP 控制器与路由声明 (参数绑定、会话获取、信封响应)
|
||||
│ └── hello/ # 业务分组/实体子包
|
||||
│ └── hello.go # 接口处理 Handler(直接以业务命名,禁止 controller_hello.go)
|
||||
│
|
||||
├── service/ # package service:业务逻辑层(用例编排、事务控制、事件发布)
|
||||
│ └── order.go # 订单业务用例实现(纯 Go 逻辑,禁止依赖 *gin.Context)
|
||||
│
|
||||
├── dao/ # package dao:数据访问持久化层 DAL (GORM CRUD、SQL 转义防注入)
|
||||
│ └── order.go # 订单数据访问实现(直接以业务命名,禁止 dao_order.go)
|
||||
│
|
||||
├── model/ # package model:纯数据实体与 DTO(无外部依赖)
|
||||
│ ├── entity/ # 数据库映射实体 (TableName() 带插件专属前缀)
|
||||
│ │ └── order.go
|
||||
│ └── do/ # 请求 Request DTO 与响应 Response DTO、领域对象
|
||||
│ └── order.go
|
||||
│
|
||||
└── migrations/ # 专属嵌入式 Goose SQL 双方言迁移脚本 (//go:embed)
|
||||
├── postgres/ # PostgreSQL 迁移脚本
|
||||
└── sqlite/ # SQLite 迁移脚本
|
||||
```
|
||||
> ⚠️ **严禁**:严禁在根目录平铺 `handlers_*.go`、`service_*.go`、`dao_*.go` 等前缀文件,子包内文件直接按业务实体命名。严格约束 `controller -> service -> dao -> model` 单向依赖。
|
||||
|
||||
---
|
||||
|
||||
@@ -127,7 +63,7 @@ internal/
|
||||
在 `internal/apps/custom/routers.go` 中编写 Handler:
|
||||
- 负责请求参数绑定与校验(使用 `ShouldBindJSON`/`ShouldBindQuery`)。
|
||||
- 负责提取 Session / 用户身份。
|
||||
- 调用业务逻辑层,并使用 `github.com/Rain-kl/Wavelet/internal/shared/response` 统一返回响应:
|
||||
- 调用业务逻辑层,并使用 `OpenFlare/internal/shared/response` 统一返回响应:
|
||||
- 成功时返回:`response.OK(data)` 或 `response.OKNil()`
|
||||
- 失败时返回:`response.Err(msg)`
|
||||
- 编写规范的 Swagger 注释。
|
||||
|
||||
@@ -6,8 +6,8 @@ package references
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/service"
|
||||
"github.com/Rain-kl/Wavelet/internal/util"
|
||||
"OpenFlare/internal/service"
|
||||
"OpenFlare/internal/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"OpenFlare/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"OpenFlare/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
package upload
|
||||
|
||||
import (
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
||||
"OpenFlare/internal/infra/task"
|
||||
)
|
||||
|
||||
// 异步任务类型标识。格式建议为 "{module}:{action}"。
|
||||
@@ -83,7 +83,7 @@ package upload
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
||||
"OpenFlare/internal/infra/task"
|
||||
)
|
||||
|
||||
type CleanupUnusedUploadsHandler struct{}
|
||||
@@ -114,7 +114,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
||||
"OpenFlare/internal/infra/task"
|
||||
)
|
||||
|
||||
type SendEmailPayload struct {
|
||||
@@ -169,9 +169,9 @@ func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.T
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/user"
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
||||
"OpenFlare/internal/apps/upload"
|
||||
"OpenFlare/internal/apps/user"
|
||||
"OpenFlare/internal/infra/task"
|
||||
)
|
||||
|
||||
func Register() {
|
||||
|
||||
@@ -14,10 +14,9 @@ description: "Wavelet 项目专用:当需要开发或接入新的系统通知
|
||||
Wavelet 的消息推送机制采用了**元数据驱动 + 统一触发器 + 异步任务派发**的解耦设计,其分层及职责划分如下:
|
||||
|
||||
| 目录/包名 | 职责定位 | 包含内容与设计细节 |
|
||||
| :--- | :--- | :--- |
|
||||
| **`pkg/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher`、单例 `PusherPool` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 |
|
||||
| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:<br>1. [events.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/events.go):定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。<br>2. [tasks.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/tasks.go):定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。<br>3. [routers.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/routers.go):管理端接口,负责获取事件配置列表和更新配置。 |
|
||||
| **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 事件元数据定义与 push 侧处理逻辑;**一个 Go 文件代表一个事件**。在 [register.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/custom_events/register.go) 统一装配,禁止 `init()` 副作用。 |
|
||||
| **`backend/plugins/domain/msg_gateway/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 |
|
||||
| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:<br>1. `events.go`:定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。<br>2. `tasks.go`:定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。<br>3. `routers.go`:管理端接口,负责获取事件配置列表和更新配置。 |
|
||||
| **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 事件元数据定义与 push 侧处理逻辑;**一个 Go 文件代表一个事件**。在 `register.go` 统一装配,禁止 `init()` 副作用。 |
|
||||
| **`internal/listener/`** | 域事件分发层 | 核心域发射事件(如 `EmitAdminLoggedIn`),push 在 bootstrap 阶段通过 `OnAdminLoggedIn` 订阅,避免 auth/user 直接依赖 push。 |
|
||||
| **`internal/platform/bootstrap/`** | 应用装配根 | `RegisterPushDomainEvents()` 调用 `custom_events.Register()`;`Init` 中执行 `SyncEvents` 将内置事件元数据同步到数据库。 |
|
||||
| **数据库审计表** | 状态与历史审计 | `w_push_events` 存放每个通知事件的启用状态、启用渠道、发送目标和自定义渲染模板。<br>`w_push_histories` 存放消息发送记录用于审计。 |
|
||||
@@ -38,8 +37,8 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/admin/push"
|
||||
"github.com/Rain-kl/Wavelet/internal/listener"
|
||||
"OpenFlare/internal/apps/admin/push"
|
||||
"OpenFlare/internal/listener"
|
||||
)
|
||||
|
||||
var NewUserRegistered = push.EventMetadata{
|
||||
@@ -84,7 +83,7 @@ func Register() {
|
||||
在业务逻辑完成处(如 `internal/apps/user/routers.go`)仅 import `internal/listener` 并发射事件:
|
||||
|
||||
```go
|
||||
import "github.com/Rain-kl/Wavelet/internal/listener"
|
||||
import "OpenFlare/internal/listener"
|
||||
|
||||
func Register(c *gin.Context) {
|
||||
// ... 注册成功逻辑 ...
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# Autoresearch lessons — Wavelet / Cordis quality run
|
||||
|
||||
Accumulated wisdom across iterations. Read this before forming a hypothesis.
|
||||
Weight recent lessons higher: the yardstick and codebase change under us.
|
||||
|
||||
## Lesson 1 — iterations 0-1
|
||||
**Pattern**: The project's committed gate (`golangci-lint run` with `.golangci.yml`)
|
||||
had already been driven to 0 issues by a previous run, so it could no longer
|
||||
measure anything.
|
||||
**Why it worked**: Measuring against a pinned snapshot + extra analyzers in
|
||||
`.auto/lint.ref.yaml` (hash-locked by the Guard) restored headroom and made it
|
||||
impossible to lower the number by editing the config.
|
||||
**Conditions**: Any repo whose own lint gate is already green.
|
||||
**Anti-pattern**: Optimising `tagliatelle` (325 findings) or `wrapcheck` (290).
|
||||
Those are pure cosmetics — error-message wording and tag naming. A run that
|
||||
chases them will look productive while shuffling strings.
|
||||
**Metric delta**: baseline re-established at 102 instead of a dead 0.
|
||||
|
||||
## Lesson 2 — iterations 1-4
|
||||
**Pattern**: Triage every analyzer finding for reality before "fixing" it.
|
||||
**Why it worked**: Three buckets turned out to be false positives:
|
||||
`forcetypeassert` in `core/events.go` is guarded by `returnsErr` (the handler's
|
||||
declared last out really is `error`), and both `exhaustive` switches already
|
||||
have `default:` arms — `exhaustive` only flags them because
|
||||
`default-signifies-exhaustive` defaults to false.
|
||||
**Conditions**: Always, but especially for linters whose defaults assume a
|
||||
different project convention.
|
||||
**Anti-pattern**: Adding `if !ok { ... }` branches or empty `case:` arms that
|
||||
cannot execute. That raises the score and lowers the code.
|
||||
**Metric delta**: 3 of 16 candidate linters dropped from the plan (0 gained,
|
||||
real regressions avoided).
|
||||
|
||||
## Lesson 3 — iterations 1, 4
|
||||
**Pattern**: Pair the metric drop with a mechanically provable defect: write the
|
||||
regression test, commit, then revert *only* the source files and require the
|
||||
test to fail (`.auto/prove_fix.sh`).
|
||||
**Why it worked**: It caught a live bug that no counter measures — a
|
||||
singleflight body capturing the first caller's request context, so one
|
||||
disconnecting browser poisoned every concurrent request for that image.
|
||||
Iteration 4 kept debt flat at 93 yet was the most valuable change so far.
|
||||
**Conditions**: Every behavioural fix. A change that survives its own revert is
|
||||
not a fix, it is a rename.
|
||||
**Anti-pattern**: Calling something "hardening" without a test that fails
|
||||
without it.
|
||||
**Metric delta**: 0 for the proven bug (kept under the fix gate), 8 for the rest.
|
||||
|
||||
## Lesson 4 — iteration 5
|
||||
**Pattern**: Strengthen the architecture gate; it is a generator of real,
|
||||
previously invisible debt.
|
||||
**Why it worked**: `check_cordis_architecture.sh` only grepped `go func(`, so
|
||||
`go w.run()` — the shape used by four long-lived cleanup loops — passed
|
||||
silently, each one able to take down the process on a panic. Widening the
|
||||
pattern surfaced them immediately.
|
||||
**Conditions**: Whenever a gate has been green for a long time. A green gate
|
||||
proves the checks exist, not that they cover anything.
|
||||
**Anti-pattern**: Weakening `.golangci.yml` (blocked outright by the Guard via
|
||||
`check_gate_weaken.py` + a SHA lock on the yardstick).
|
||||
**Metric delta**: 4 uncovered crash-on-panic sites hardened.
|
||||
|
||||
## Lesson 5 — iteration 3
|
||||
**Pattern**: Deduplicate by extracting the shared *classification*, not the
|
||||
shared *response*.
|
||||
**Why it worked**: Two handlers mapped upload-lookup errors with copy-pasted
|
||||
blocks that had quietly drifted (different fallback status, different synonym
|
||||
constant for the same message). `filesrv.AbortUploadRecordError` handles the
|
||||
200/400 branches, and each endpoint keeps its own fallback it can still
|
||||
justify. Deleting the orphaned `ErrInvalidUploadID` constant was part of the
|
||||
change, not extra cleanup.
|
||||
**Conditions**: Duplicated error-mapping or validation blocks in sibling handlers.
|
||||
**Anti-pattern**: Silently unifying HTTP status codes across endpoints to make a
|
||||
helper fit — that is a behaviour change wearing a refactor's clothes.
|
||||
**Metric delta**: -2.
|
||||
|
||||
## Lesson 6 — iterations 15-21
|
||||
**Pattern**: Delegate a broad read-only audit for what mechanical gates cannot
|
||||
see (N+1s, locks held over I/O, resource leaks, layering), then re-verify each
|
||||
claim yourself before touching code.
|
||||
**Why it worked**: The audit produced the run's best findings — the per-request
|
||||
CORS database query, the orphan cron dispatching to a task nobody registered,
|
||||
media temp dirs nothing ever removed. It also produced a wrong one: it asserted
|
||||
telebot falls back to `http.DefaultClient` with no timeout, when telebot itself
|
||||
constructs a client with a one minute deadline. Acting on that would have added
|
||||
a tunable dressed up as a bug fix.
|
||||
**Conditions**: Whenever the committed gates are green and the easy signal is
|
||||
exhausted.
|
||||
**Anti-pattern**: Trusting an audit summary's file:line as evidence. One
|
||||
referenced file did not exist.
|
||||
**Metric delta**: 0 for three landed fixes (all kept under the proven-fix gate),
|
||||
but they were the run's highest-impact changes.
|
||||
|
||||
## Lesson 7 — iteration 16
|
||||
**Pattern**: Prove query-reduction with a functional test double that counts
|
||||
loader invocations, and assert the counter for both the batch and the looped
|
||||
form in the same test.
|
||||
**Why it worked**: Asserting "1 query" alone is vacuous — it also passes when
|
||||
nothing ran. Asserting batch=1 and per-id=3 in one test makes the instrument
|
||||
itself checked, so the claim cannot silently degrade.
|
||||
**Conditions**: Any change whose whole value is doing less I/O.
|
||||
**Anti-pattern**: Fixing an N+1 by reaching around the contract into another
|
||||
plugin's repository. The layering was the reason the slow path existed; the
|
||||
right move was to extend the contract with a batch method.
|
||||
**Metric delta**: 0 (kept under the proven-fix gate).
|
||||
|
||||
## Lesson 8 — iterations 17-22
|
||||
**Pattern**: Strengthen a gate only alongside the code that satisfies it, and
|
||||
never rewrite history in a shared worktree.
|
||||
**Why it worked**: Deleting 24 dead lint suppressions paid off exactly as the
|
||||
self-correcting design predicted: two of them were load-bearing under the
|
||||
project's own gate even though the analyzer called them unused, the Guard
|
||||
vetoed, and their removal surfaced two verified `contextcheck` false positives
|
||||
worth documenting instead of silently swallowing. Meanwhile a concurrent
|
||||
session was committing plan documents in the same tree, so `git add -A` swept
|
||||
one of its in-flight edits into my commit — unfixable by rebase without
|
||||
destroying their work, so the repair was to stage explicit paths from then on.
|
||||
**Conditions**: Always, in this repo. Assume another agent is editing `docs/`
|
||||
and `backend/core` concurrently.
|
||||
**Anti-pattern**: `git add -A` outside the first setup commit. Also: trusting
|
||||
"unused directive" as "safe to delete" — check the strictest config, not just
|
||||
the pinned yardstick.
|
||||
**Metric delta**: -25 in one iteration.
|
||||
|
||||
## Lesson 9 — iterations 23-24
|
||||
**Pattern**: Cross-check every service a plugin's `Apply` reads out of the
|
||||
container against what that plugin's `Inject()` declares. `Inject()` is the only
|
||||
thing `App.reconcileLocked` gates on, so anything consumed as a *value* at Apply
|
||||
time but left undeclared is resolved from a container that may not hold it yet.
|
||||
**Why it worked**: It found the run's worst defect, invisible to every
|
||||
mechanical gate: `user` declared only `DBService` while capturing
|
||||
`contracts.AuthService` to build its route guard, and `cmd/app.go` lists `user`
|
||||
before `auth`. Because user's dep set is a strict subset of auth's and it sits
|
||||
earlier in the slice, user *always* mounts first — deterministically, not a
|
||||
race — so `loginMW` fell back to a `c.Next()` closure and
|
||||
`/api/v1/user/{change-password,profile,access-tokens}` mounted unguarded. The
|
||||
same lookups in `admin` read a package global that its own `OnDispose` nils, so
|
||||
in-flight requests fail open during dispose.
|
||||
**Conditions**: Any Cordis plugin whose Apply assigns a contract result to a
|
||||
variable used later (middleware, handler closures). Services bound through
|
||||
`core.When` late binding are exempt — that is the correct pattern for genuinely
|
||||
late deps, so do not blanket-declare everything.
|
||||
**Anti-pattern**: Assuming a checked `x, ok :=` assertion is safe. All three
|
||||
plugins used the checked form and all three failed *open* — checked syntax,
|
||||
unchecked semantics.
|
||||
**Metric delta**: 0 across both iterations (kept under the proven-fix gate),
|
||||
but this is the run's highest-severity finding. `RouterRegistry` records each
|
||||
route's `Handlers`/`Middlewares`, which makes "is this route actually guarded?"
|
||||
directly assertable from the route table — the cheapest available oracle for
|
||||
security properties here.
|
||||
|
||||
## Lesson 10 — iteration 23 review
|
||||
**Pattern**: When the remaining metric is dominated by a positional or
|
||||
taste-based analyzer, say so and refuse to spend iterations on it.
|
||||
**Why it worked**: `funcorder` was 21 of 54 findings (39%) — pure function
|
||||
*ordering within a file*. Reordering private helpers to the bottom of a file
|
||||
moves the number and changes nothing a reader or the machine cares about, which
|
||||
is Lesson 1's "looks productive while shuffling strings" with a different label.
|
||||
Skipping it kept the loop honest. Triage also cleared 12 of 13
|
||||
`forcetypeassert` (guarded by construction) and 2 of 3 `unparam` (deliberate
|
||||
constructor symmetry behind one factory switch).
|
||||
**Conditions**: Whenever one linter dominates a shrinking total, break the count
|
||||
down per linter *before* picking a hypothesis.
|
||||
**Anti-pattern**: Treating a large single-linter share as an easy win. Real
|
||||
headroom at this point is ~10 findings, so a plateau in `debt` no longer means a
|
||||
stalled loop.
|
||||
**Metric delta**: 0 spent, ~21 findings deliberately left in place.
|
||||
|
||||
## Lesson 11 — iterations 24-25
|
||||
**Pattern**: Run the Guard after every single commit, and confirm which commit a
|
||||
proof script is actually reverting against.
|
||||
**Why it worked**: Four `staticcheck ST1023` findings from iteration 24 shipped
|
||||
straight through `go build ./...` and a green 47-package `go test ./...` —
|
||||
neither runs the project linter, so only `checks.sh` section 3 catches them.
|
||||
Separately, `prove_fix.sh` reverts to `HEAD^`; appending the iteration-23 log
|
||||
commit shifted `HEAD^` to the *fixed* state and reported "PROVE FAILED: tests
|
||||
still pass without the fix" on a genuinely load-bearing fix. Re-checking against
|
||||
the explicit pre-fix commit (`git checkout <sha> -- <files>`) showed the real
|
||||
answer. A false negative here is worse than no proof: it reads like the fix was
|
||||
cosmetic.
|
||||
**Conditions**: Always. Also note zsh does not word-split unquoted variables, so
|
||||
`git checkout $FILES` passes one bogus pathspec and silently reverts nothing —
|
||||
the command still exits 0.
|
||||
**Anti-pattern**: Batch-verifying at ship time. And any shell loop built on the
|
||||
bash word-splitting habit in this environment.
|
||||
**Metric delta**: -0, 1 wrong verdict corrected.
|
||||
|
||||
## Lesson 12 — iterations 27-32
|
||||
**Pattern**: Two things produced every substantive win: (1) find a place where
|
||||
correctness rests on a *prose comment* instead of an enforced constraint, and (2)
|
||||
find immutable startup work being redone inside a request path.
|
||||
**Why it worked**: Lint cannot see either class, so `debt` barely moved while real
|
||||
defects did. The comment "field comes from call sites, never from user input" sat
|
||||
on a function that interpolated its column argument straight into `WHERE` — the
|
||||
tautology payload executed and returned a row with `err=<nil>`, a filter bypass,
|
||||
not a hypothetical. The comment "contracts are pure abstractions" sat on a DTO
|
||||
carrying `TableName()`, which is exactly the handle four plugins used to read
|
||||
`w_users` instead of calling `UserService`. On the second pattern, three packages
|
||||
each re-normalised and re-split static whitelist patterns per request: hoisting
|
||||
that to registration cut 14 allocs/op to 1.
|
||||
**Conditions**: Any exported function taking a string that reaches SQL, a path
|
||||
matcher, or a shell. Any loop over configuration inside a request handler.
|
||||
**Anti-pattern**: Believing `nolintlint`'s "unused directive" means "safe to
|
||||
delete" — hit twice now, and the project gate vetoed it both times. Also believing
|
||||
a doc comment's self-assessment: verify the claim or leave it alone.
|
||||
**Metric delta**: 64 -> 63 across five keeps. Four of the five kept changes had
|
||||
delta 0. Under a pure-debt loop this run would have looked stalled while fixing a
|
||||
security bypass and a hot-path allocation bug.
|
||||
|
||||
## Standing notes
|
||||
- **The golangci-lint cache is machine-wide** (`~/.cache/golangci-lint`), so a
|
||||
sibling worktree analysing identical sources replays here carrying *that*
|
||||
checkout's absolute paths — 12 of 63 findings pointed outside the repo, which
|
||||
misattributes findings and can serve a stale Guard verdict. `measure.sh` and
|
||||
`checks.sh` now key `GOLANGCI_LINT_CACHE` per checkout (iteration 31). It is
|
||||
count-neutral (cold and warm both 63), but check path attribution before
|
||||
trusting any finding's location.
|
||||
- **Do not delegate a repo-wide audit to one subagent.** Both broad audits
|
||||
(architecture, bugs/perf) hit the 150-turn cap after ~45M tokens combined and
|
||||
returned nothing usable. Everything this run found came from targeted inline
|
||||
greps followed by reading the specific function. If delegating, bound it to one
|
||||
package cluster and a small finding budget.
|
||||
- Run decisions for this run: real defects first with `debt` as a secondary gate,
|
||||
commits directly on `main`, small file moves allowed but large package
|
||||
restructuring goes to a written proposal first.
|
||||
- Upstream moves fast in this repo: `origin/main` gained 11 commits mid-run
|
||||
(Cordis config extension point — `ctx.Config().Bind`, `DeclareConfig()`,
|
||||
`core.ConfigGatedPlugin`), which raised measured `debt` 54 -> 64 and
|
||||
`nolint_dirs` 72 -> 73 on its own. Rebase early and re-run the Guard after;
|
||||
a clean rebase does not mean a green one.
|
||||
- `cmd.TestNewWaveletAppWithRedisEnabled` needs a live Redis on
|
||||
`127.0.0.1:6379` and fails without one. Pre-existing on `origin/main`, so
|
||||
`tests_passed` 46 vs 47 is environmental, not a regression. Confirm against a
|
||||
scratch `git worktree` of `origin/main` before blaming a change for it.
|
||||
- Repo facts: backend module rooted at `backend/`, gofumpt orders a single
|
||||
import group as `Wavelet/...` before stdlib (uppercase sorts first); new Go
|
||||
files need the Apache license header or `scripts/update_go_license.sh --check`
|
||||
fails the Guard.
|
||||
- Handler edits require `make swagger` (cheap: it regenerates identical docs
|
||||
when only bodies change).
|
||||
- Dead suppressions are tracked by the `nolint_dirs` counter; removing one that
|
||||
is still needed re-raises the original finding, so the metric self-corrects.
|
||||
24 were removed in iteration 22; 72 remain, each still doing work (73 after
|
||||
the upstream rebase).
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Autoresearch baseline — captured at iteration #0 (2026-08-29).
|
||||
# The Guard compares live values against these floors; the PRIMARY metric is debt.
|
||||
BASE_DEBT=102
|
||||
BASE_NOLINT=96
|
||||
BASE_TESTS_PASSED=46
|
||||
BASE_TEST_FUNCS=232
|
||||
BASE_TEST_FILES=76
|
||||
BASE_ARCH_VIOL=0
|
||||
BASE_COVERAGE=34.09
|
||||
|
||||
# SHA-256 of the pinned yardstick config. Guard aborts if it changes.
|
||||
REF_SHA=e881bda167bd688489f1356b7cd4056b8a6960f48b6778b095bdd2e44f627b82
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anti-cheat: prove .golangci.yml was only ever strengthened, never weakened.
|
||||
|
||||
Compares the live gate against the immutable snapshot taken at run start.
|
||||
Exits non-zero with a reason if any hardening rule is violated.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
BASELINE = os.path.join(ROOT, ".auto", "gate.baseline.yml")
|
||||
LIVE = os.path.join(ROOT, ".golangci.yml")
|
||||
|
||||
# threshold-like knobs: (path, direction) where direction "max" means the value
|
||||
# is an upper bound (smaller == stricter), "min" means a lower bound.
|
||||
STRICTNESS = [
|
||||
(("linters", "settings", "dupl", "threshold"), "max"),
|
||||
(("linters", "settings", "cyclop", "max-complexity"), "max"),
|
||||
(("linters", "settings", "cyclop", "package-average"), "max"),
|
||||
(("linters", "settings", "nestif", "min-complexity"), "min"),
|
||||
(("linters", "settings", "funlen", "lines"), "max"),
|
||||
(("linters", "settings", "funlen", "statements"), "max"),
|
||||
(("linters", "settings", "gocyclo", "min-complexity"), "min"),
|
||||
(("linters", "settings", "lll", "line-length"), "max"),
|
||||
]
|
||||
|
||||
|
||||
def load(path):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return yaml.safe_load(fh) or {}
|
||||
|
||||
|
||||
def dig(doc, path):
|
||||
node = doc
|
||||
for key in path:
|
||||
if not isinstance(node, dict) or key not in node:
|
||||
return None
|
||||
node = node[key]
|
||||
return node
|
||||
|
||||
|
||||
def enabled_linters(doc):
|
||||
lint = doc.get("linters") or {}
|
||||
if lint.get("enable-presets"):
|
||||
return None # preset based; fall back to "any removal is suspicious"
|
||||
return set(lint.get("enable") or [])
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
base, live = load(BASELINE), load(LIVE)
|
||||
except OSError as exc:
|
||||
print(f"gate snapshot unreadable: {exc}")
|
||||
return 1
|
||||
except yaml.YAMLError as exc:
|
||||
print(f".golangci.yml is not parseable: {exc}")
|
||||
return 1
|
||||
|
||||
problems = []
|
||||
|
||||
base_lint, live_lint = base.get("linters") or {}, live.get("linters") or {}
|
||||
if (base_lint.get("default") or "none") != (live_lint.get("default") or "none"):
|
||||
problems.append("linters.default changed")
|
||||
|
||||
base_set, live_set = enabled_linters(base), enabled_linters(live)
|
||||
if base_set is None or live_set is None:
|
||||
if set((base.get("linters") or {}).get("enable-presets") or []) - set(
|
||||
(live.get("linters") or {}).get("enable-presets") or []
|
||||
):
|
||||
problems.append("an enable-preset was removed")
|
||||
elif dropped := base_set - live_set:
|
||||
problems.append(f"linters disabled: {sorted(dropped)}")
|
||||
|
||||
for path, direction in STRICTNESS:
|
||||
old, new = dig(base, path), dig(live, path)
|
||||
if old is None or new is None:
|
||||
continue
|
||||
try:
|
||||
old_f, new_f = float(old), float(new)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if direction == "max" and new_f > old_f:
|
||||
problems.append(f"{'.'.join(path)} loosened {old} -> {new}")
|
||||
if direction == "min" and new_f < old_f:
|
||||
problems.append(f"{'.'.join(path)} loosened {old} -> {new}")
|
||||
|
||||
base_mnd = set(dig(base, ("linters", "settings", "mnd", "checks")) or [])
|
||||
live_mnd = set(dig(live, ("linters", "settings", "mnd", "checks")) or [])
|
||||
if base_mnd - live_mnd:
|
||||
problems.append(f"mnd checks dropped: {sorted(base_mnd - live_mnd)}")
|
||||
|
||||
issues_live = live.get("issues") or {}
|
||||
for key in ("exclude-rules", "exclude-patterns"):
|
||||
if issues_live.get(key) and not (base.get("issues") or {}).get(key):
|
||||
problems.append(f"issues.{key} added (suppresses reporting)")
|
||||
|
||||
for key in ("max-issues-per-linter", "max-same-issues"):
|
||||
old = (base.get("issues") or {}).get(key)
|
||||
new = issues_live.get(key)
|
||||
if old == 0 and new != 0:
|
||||
problems.append(f"issues.{key} no longer 0 — findings would be truncated")
|
||||
|
||||
# Exclusions expressed through the newer 'linters.exclusions' block.
|
||||
if (live_lint.get("exclusions") or {}) and not (base_lint.get("exclusions") or {}):
|
||||
problems.append("linters.exclusions added")
|
||||
|
||||
if problems:
|
||||
print("\n".join(f" - {p}" for p in problems))
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# Correctness gate: must pass after every edit. Fails fast on real breakage.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> go vet ./..."
|
||||
go vet ./... 2>&1 | tail -20
|
||||
|
||||
echo "==> go build ./..."
|
||||
go build ./... 2>&1 | tail -20
|
||||
|
||||
echo "==> golangci-lint run (repo config)"
|
||||
golangci-lint run 2>&1 | tail -20
|
||||
|
||||
# 全量单测(sqlite + miniredis,纯本地无需外部服务;2026-08-16 起全绿)
|
||||
echo "==> go test ./internal/... ./pkg/..."
|
||||
go test ./internal/... ./pkg/... 2>&1 | grep -E "^--- FAIL|^FAIL" | head -20 || true
|
||||
if go test ./internal/... ./pkg/... > /tmp/auto_gotest.log 2>&1; then
|
||||
:
|
||||
else
|
||||
tail -30 /tmp/auto_gotest.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 前端测试(vitest;2026-08-16 起全绿)
|
||||
echo "==> pnpm exec vitest run (frontend)"
|
||||
(cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm exec vitest run --reporter=dot > /tmp/auto_vitest.log 2>&1) || {
|
||||
tail -30 /tmp/auto_vitest.log
|
||||
exit 1
|
||||
}
|
||||
|
||||
# SPDX license 头门禁(repo 自带约定)
|
||||
echo "==> make license-check"
|
||||
make license-check 2>&1 | grep "needs license" | head -10 || true
|
||||
if make license-check > /tmp/auto_license.log 2>&1; then
|
||||
:
|
||||
else
|
||||
tail -15 /tmp/auto_license.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 并发密集包 -race 门禁(2026-08-16 全仓 -race 清零后纳入,防回归;
|
||||
# frpc/frps 慢套件不含在此,另做全量周期验证)
|
||||
echo "==> go test -race (concurrency packages)"
|
||||
RACE_PKGS="./internal/apps/oauth/ ./internal/apps/openflare/tls/ ./internal/apps/openflare/uptimekuma/ ./internal/apps/upload/cache/ ./internal/repository/ ./pkg/cache/disk/ ./pkg/logger/ ./internal/infra/persistence/batchwriter/"
|
||||
if go test -race -count=1 $RACE_PKGS > /tmp/auto_race.log 2>&1; then
|
||||
:
|
||||
else
|
||||
grep -E "WARNING: DATA RACE|^--- FAIL|^FAIL" /tmp/auto_race.log | head -20
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: checks passed"
|
||||
@@ -0,0 +1,61 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: false
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
# 基础检查
|
||||
- govet
|
||||
- staticcheck
|
||||
- errcheck
|
||||
- ineffassign
|
||||
- unused
|
||||
|
||||
# 代码坏味道
|
||||
- dupl # 重复代码
|
||||
- mnd # 魔法数字
|
||||
- goconst # 不必要的字符串常量
|
||||
- cyclop # 包/函数复杂度
|
||||
- nestif # if 嵌套太深
|
||||
- maintidx # 维护性指数
|
||||
- revive # 风格/命名/坏味道
|
||||
- gocritic # 各类代码问题
|
||||
- funlen # 函数过长
|
||||
|
||||
- gosec # 安全问题检查
|
||||
- bodyclose # HTTP response body 没有正确关闭
|
||||
- noctx # 没有传递 context.Context
|
||||
- contextcheck # 其他检查
|
||||
- sqlclosecheck # SQL rows 没有正确关闭
|
||||
- unconvert # 不必要的类型转换
|
||||
- nilerr # 函数返回 nil 错误
|
||||
|
||||
settings:
|
||||
dupl:
|
||||
threshold: 80
|
||||
|
||||
cyclop:
|
||||
max-complexity: 20
|
||||
package-average: 10
|
||||
|
||||
nestif:
|
||||
min-complexity: 5
|
||||
|
||||
funlen:
|
||||
lines: 200
|
||||
statements: 100
|
||||
|
||||
mnd:
|
||||
checks:
|
||||
- argument
|
||||
- condition
|
||||
- return
|
||||
|
||||
|
||||
# 完整上报所有问题(取消 golangci 默认 50/3 截断,保证 code-check 与度量真实)
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# Ideas backlog (代码质量)
|
||||
|
||||
## 已尝试并收尾(2026-08-16 会话,14 个实验,108→8)
|
||||
|
||||
- 生产代码 golangci 扩展集 13 类 linter 全量清理(modernize/perfsprint/
|
||||
errorlint/canonicalheader/usestdlibvars/intrange/wastedassign/errname/
|
||||
forcetypeassert/prealloc/gosec/recvcheck/exhaustive),剩余 8 处全部为
|
||||
有据可查的刻意保留项(telegram %v、3 处嵌套 struct omitempty、
|
||||
3 处 not-found 惯例、1 处 encoding/json 接收者混合)。
|
||||
- 测试代码质量维度(testifylint/usetesting/thelper)25→0。
|
||||
- 前端 eslint/tsc 0。
|
||||
- 修复中积累的工具经验:golangci-lint v2 `--fix` 的 import 管理不可靠,
|
||||
跑完必须 `goimports -w`;`--max-issues-per-linter=0` 才能拿到全量清单
|
||||
(默认 50 + max-same-issues=3 会掩盖重复模式);cyclop 与 exhaustive
|
||||
有张力(显式 case 计入复杂度)。
|
||||
|
||||
## 未来可深化方向(均经评估)
|
||||
|
||||
- 测试可运行性修复:`go test ./internal/...` 目前在 main 上就有失败
|
||||
(无本地 redis、frpc 进程测试 flaky)。修复这些环境问题后,可以把
|
||||
`go test` 加入 checks.sh,解锁 paralleltest/tparallel 维度
|
||||
(t.Parallel 提速 + 正确性,目前因共享状态+不可运行而放弃)。
|
||||
- frontend biome 格式漂移(76 文件):一次性 `make format` 提交,
|
||||
与质量修复分开做,不进基准。
|
||||
- fieldalignment:结构体内存布局优化,但会改变 JSON key 顺序且有
|
||||
位置字面量风险 —— 若做,需按文件人工核对,不进自动基准。
|
||||
- Go 1.26 新特性扫描:`go vet` 新分析器、golangci-lint 新 linter
|
||||
(如 recvcheck 之后的 new receivers 检查)随版本跟进。
|
||||
- 文档/示例代码(docs/、scripts/)质量:目前不在 golangci 范围(tests:false
|
||||
之外还有 scripts 目录),可用同一扩展集扫 scripts/ 下的 main.go。
|
||||
|
||||
## 会话收尾(2026-08-16,run #23 后)
|
||||
|
||||
- 已确认收敛:基准 5 维全下限、-race 全仓清零、双端测试全绿、发布构建可复现、
|
||||
config.example.yaml ↔ model.go 同步无漂移、无 flaky 测试。
|
||||
- 明确评估为不值得做的方向:paralleltest/tparallel(共享全局状态风险)、
|
||||
fieldalignment(JSON key 顺序变化)、biome 格式漂移(纯噪声)、
|
||||
frpc/frps 慢测试注入 backoff(为省 ~40s 改生产时序逻辑,不值)。
|
||||
- 未来如继续:可周期跑 `go test -race ./...` 全量(frpc/frps 慢套件);
|
||||
或前端 a11y 用 axe 做浏览器级审计(超出 eslint 静态规则)。
|
||||
|
||||
## 本会话新增(runs #39-#43)
|
||||
|
||||
已修复:
|
||||
- agent auth_cache negative 缓存无上限 → 10k 上限+过期清理(DoS 防护)
|
||||
- relay/flared 与 agent 三份重复 authenticateAccessToken → 共享 agent 版(负缓存共享,DB 压力下降)
|
||||
- websocket 三 hub:runWritePump 抽取、wsClientCore 嵌入(close/enqueue 单份)、broadcastAgent 合并
|
||||
- frps/frpc TOML 注入 → pkg/protocol/toml.go TOMLQuote 转义全部插值
|
||||
|
||||
评估后不修/暂缓:
|
||||
- cloudflare listMemberItems、config_version snapshot 证书循环的 N+1:管理端小 N 低频,
|
||||
加批量 repo API 属投机优化;若未来组员数量变大再做 ListZoneDomainsByIDs。
|
||||
- fatcontext ×3(oauth/upload/auth_source cache listener):别名赋值误报,非嵌套包装。
|
||||
- objectstore newOSSBackend/newWebDAVBackend 恒 nil error:跨后端工厂签名统一,刻意设计。
|
||||
- edge/updater assetNameForGOOSGOARCH 恒 "linux":跨平台预留参数,刻意泛化。
|
||||
- agent ResolverDirective explicitResolvers 原样插入 nginx conf:管理员配置属可信输入;
|
||||
若未来开放给低权限角色需加格式校验(IP 解析)。
|
||||
- pkg/render/openresty 管理端旋钮(ClientMaxBodySize 等)原样插值:管理员权限范围内。
|
||||
- frontend/settings/profile.tsx(858 行)超 AGENTS.md ~600 行指引:存量组件,拆分属
|
||||
纯重构无质量增益,暂缓;若后续要改该页面功能时顺手拆 components/。
|
||||
|
||||
## Run #44(全仓 -race 扫描)
|
||||
|
||||
- 发现并修复 upload/cache 监听器 DATA RACE:goroutine 读可变全局 db.Redis vs
|
||||
testhelper 清理置 nil。根因修复=启动时捕获 redisClient(oauth×2/repository×2
|
||||
同型监听器一并加固),StopUploadMetaCacheListener 补 done 等待。
|
||||
- 教训:testhelper 不能 import upload/cache(循环依赖);"捕获替代全局读"是
|
||||
无环的根因修法。
|
||||
- 全仓 -race 现为 0 竞争(internal/... + pkg/...);建议周期性重跑。
|
||||
|
||||
## LIKE 转义(本轮已修日志搜索 4 站点;同类遗留)
|
||||
|
||||
- 已修:analytics/node_access_log_filter.go、analytics/access_log_filter.go、
|
||||
logstore/postgres_store.go×2(PG/SQLite 加 ESCAPE '\',CH 用默认反斜杠转义)。
|
||||
新助手 pkg/util/like.go EscapeLike + 单测。
|
||||
- Run #47 已收尾全部 GORM 站点:upload.go keyword、user.go:73/76/188/229
|
||||
(含 OAuth uniqueUsername base 转义——外部输入含 _ 曾误报用户名冲突)、
|
||||
task_execution.go task_type 前缀。均加显式 ESCAPE '\'。
|
||||
- 刻意保留:upload.go:199 `image/%`(系统常量)、config_version.go:65(系统生成)。
|
||||
|
||||
## Run #48(后台 goroutine panic 防护,55db1c01)
|
||||
|
||||
- 全仓 20 处裸 go func() 零 recover → 新增 pkg/util/goroutine.go `Go(fn)`(recover +
|
||||
slog + debug.Stack,runtime.Caller 自动记录调用点无需手写名字),22 个站点全部收口
|
||||
(oauth/upload/system_config/auth_source 的嵌套 ctx-done watcher 也含)。
|
||||
- 教训:脚本括号深度匹配首轮会跳过嵌套内层 goroutine,需跑两轮;新 Go 文件必须先跑
|
||||
scripts/update_go_license.sh(license-check 会拦)。
|
||||
- 已过期记录:go test ./internal/... ./pkg/... 现全过(94 ok)——"main 上测试失败"
|
||||
不再成立。scripts/、docs/ 下 Go 文件用扩展 linter 扫过:0 issues。
|
||||
|
||||
## Run #50(发现型 linter 扫描,全证伪——勿重跑这些维度)
|
||||
|
||||
- errchkjson 12 处:全部为不可能失败的 json.Marshal(纯 string/int/[]string
|
||||
结构体;admin/logs/routers.go:131 与 waf/ip_group_sync.go:255 的 "unsafe type"
|
||||
是传递性保守标记,RawMessage/time.Time 内容来自必然成功的 marshal)。
|
||||
- spancheck 1 处(pkg/trace/trace.go:61):误报,helper 正常返回 span,
|
||||
唯一调用方 internal/infra/task/executor.go:242 有 defer span.End()。
|
||||
- unparam ×2(objectstore oss/webdav 恒 nil error):已在 #43 前评估为跨后端工厂签名统一。
|
||||
- 性能排查:正则全部包级编译(无函数内 MustCompile);包级 map 全为有界静态注册表;
|
||||
task AppendLog 走 DB 非内存累积;push escapeJSONString 用法正确。
|
||||
- 结论:Go 静态可发现的低垂果实已穷尽。剩余方向:frontend axe a11y 浏览器级审计、
|
||||
周期性 -race 重跑(上次 #49 干净)、运维类增长审查。
|
||||
|
||||
## Run #54(认证页 axe a11y 审计+修复,451ce525)
|
||||
|
||||
已修(复扫验证生效):
|
||||
- 布局级全局:sidebar 折叠按钮 aria-label、Sidebar role=navigation(region 18 节点/页清零)、
|
||||
header Kbd 对比度 text-foreground/70、空态/错误/加载 h3→p(heading-order 清零)。
|
||||
- 页面级:dashboard 4 个 Progress aria-label、users 分页 prev/next aria-label、
|
||||
admin/system 无内容 Tabs→aria-pressed 按钮组(aria-valid-attr-value critical 清零)。
|
||||
- / 与 /admin/system 现 axe 0 违规。
|
||||
|
||||
后续可做(页面级批量,工作量大):
|
||||
- admin 数据表格行内操作图标按钮(编辑/删除)与 Switch 开关无 aria-label —— 每张管理表逐个补;
|
||||
- muted 文本对比度(card description、radix tabs trigger、primary 按钮文字)—— shadcn 默认色在浅色主题下 axe 判 fail,改主题变量影响面大需设计确认。
|
||||
- 审计环境复用:后端 :3100 + CONFIG_PATH=/tmp/of-audit/config.yaml(sqlite)、docker redis --network host、
|
||||
pnpm dev --port 3002 WAVELET_BACKEND_URL=:3100;admin 密码 reset-passwd 重置。注意 :3000 是生产实例勿动。
|
||||
|
||||
## Run #54-#55(认证页 a11y 审计,两轮 keep)
|
||||
|
||||
已修复(浏览器 axe 复扫验证):
|
||||
- 全局布局:sidebar 折叠按钮 aria-label、Sidebar role=navigation、header Kbd 对比度、
|
||||
dashboard Progress aria-label、分页 prev/next、空态/加载 h3→p、admin/system Tabs→aria-pressed。
|
||||
- 主题级根因:--primary indigo-500(#6366f1) 白字对比度仅 4.27(AA 需 4.5) → indigo-600
|
||||
oklch(51.1% 0.262 276.966) ≈6.8,一处修复全站 contrast 清零。
|
||||
- 控件名:access-analytics 刷新、events-tab Switch/编辑/删除、openflare-ops Switch/Select/
|
||||
Input(htmlFor)/Textarea、table-browser/sql-console SelectTrigger;heading-order:眉题
|
||||
h4→p(cache-manager/user-detail-sheet)、卡片题 h3→p(task-manager/file-manager)。
|
||||
- 结果:dashboard、admin/system、admin/settings、admin/logs、admin/push、admin/tasks、
|
||||
admin/database、files 共 8 页 axe 0 违规。
|
||||
|
||||
审计方法(可复用):后端 :3100(CONFIG_PATH=/tmp/of-audit/config.yaml,sqlite,
|
||||
api_prefix 必须显式 /api)+ docker redis --network host(本机 bridge NAT 坏)+
|
||||
pnpm dev --port 3002 WAVELET_BACKEND_URL=:3100 + admin 密码经 reset-passwd 重置。
|
||||
axe 注入:eval 建 CDN script → Promise 轮询 window.axe → axe.run。
|
||||
教训:表单页异步渲染,须 wait≥5s 再扫否则漏报 label 规则;Radix SelectValue
|
||||
value='' 时 placeholder 不显示,combobox 无名需 aria-label 兜底。
|
||||
|
||||
## 剩余可做
|
||||
|
||||
- 抽查其余页面(websites/[zoneId]、origins/detail、responses 编辑器等富交互页)
|
||||
——contrast 已由主题修复覆盖,预期只剩个别控件名。
|
||||
- 周期性 go test -race ./... 全量重跑(上次干净为 run #49 后)。
|
||||
|
||||
## Run #56(富交互页抽查,keep,63e3b852)
|
||||
|
||||
- 扫描 11 页:websites/origins/proxy-routes/certificates/dns-accounts 直接 0 违规
|
||||
(indigo-600 主题修复已覆盖全站 contrast)。
|
||||
- 修复 3 处并复扫归零:
|
||||
1. cloudflare/components/sync-tasks-panel.tsx 状态筛选 SelectTrigger 加 aria-label
|
||||
(Radix SelectValue value='' 时 placeholder 不渲染,combobox 无名)。
|
||||
2. components/common/settings/access-token.tsx 安全提示 text-amber-600→amber-700
|
||||
(12px 小字对比度不足)。
|
||||
3. settings/notifications 面包屑页缺 h1 → sr-only h1。教训:h1 不能作为
|
||||
BreadcrumbList 子元素(axe list 规则报 list 语义破坏),须放 <Breadcrumb> 外;
|
||||
BreadcrumbPage 无 asChild 支持。
|
||||
- a11y 维度至此穷尽:累计 14 页 axe 全部 0 违规。
|
||||
|
||||
## Run #59(-shuffle=on 测试顺序随机化扫描,keep,b56f2763)
|
||||
|
||||
- 新维度:`go test -shuffle=on` 抓到 config_version 包测试顺序依赖——
|
||||
TestBuildOpenRestyConfigSnapshotOriginErrorPageDefaults 在 shuffle 下命中
|
||||
Custom 用例留在进程级 RAM 配置缓存的值(GetSystemConfigByGroup 未命中时
|
||||
ram.Set 回填,TTL 跨测试存活;:memory: DB + SetDB 换库不使缓存失效)。
|
||||
- 修复:setupOriginErrorPageSnapshotDB / setupConfigVersionTestDB 换 DB 前后
|
||||
接入既有 ram.ResetForTest()。包内 shuffle×8 + 全仓 shuffle 复扫全过。
|
||||
- 教训:默认源码顺序掩盖顺序依赖;-shuffle=on 是低成本周期扫描手段。
|
||||
全仓 -race(#58 后)同样干净。其余用 SetDB 的测试包如后续 shuffle 复发,
|
||||
同法接入 ResetForTest 即可。
|
||||
@@ -0,0 +1,78 @@
|
||||
version: "2"
|
||||
|
||||
# Pinned autoresearch yardstick. IMMUTABLE for the duration of a run.
|
||||
# Snapshot of the committed .golangci.yml plus the extra analyzers that report
|
||||
# genuine defects (correctness / panics / dead code) rather than cosmetics.
|
||||
# Keeping this separate from .golangci.yml means strengthening the project gate
|
||||
# can never silently lower the measured debt.
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: false
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
# --- from the committed project gate ---
|
||||
- govet
|
||||
- staticcheck
|
||||
- errcheck
|
||||
- ineffassign
|
||||
- unused
|
||||
- dupl
|
||||
- mnd
|
||||
- goconst
|
||||
- cyclop
|
||||
- nestif
|
||||
- maintidx
|
||||
- revive
|
||||
- gocritic
|
||||
- funlen
|
||||
- gosec
|
||||
- bodyclose
|
||||
- noctx
|
||||
- contextcheck
|
||||
- sqlclosecheck
|
||||
- unconvert
|
||||
- nilerr
|
||||
# --- extra real-risk analyzers (defects, not cosmetics) ---
|
||||
- errorlint # err == / %v instead of errors.Is/As and %w
|
||||
- forcetypeassert # unchecked type assertions can panic
|
||||
- nilnil # (value, nil) breaks the nil-check contract
|
||||
- predeclared # shadowing builtins
|
||||
- unparam # dead params/results
|
||||
- wastedassign # dead stores
|
||||
- exhaustive # enum switches missing cases
|
||||
- makezero # append to preallocated slice
|
||||
- rowserrcheck # sql.Rows error after iteration
|
||||
- durationcheck # multiplied time.Duration
|
||||
- prealloc # slice growth in loops
|
||||
- copyloopvar # loop-var capture
|
||||
- nonamedreturns
|
||||
- funcorder # struct methods scattered across files
|
||||
- nolintlint # suppression audit (must stay 0)
|
||||
|
||||
settings:
|
||||
dupl:
|
||||
threshold: 80
|
||||
|
||||
cyclop:
|
||||
max-complexity: 20
|
||||
package-average: 10
|
||||
|
||||
nestif:
|
||||
min-complexity: 5
|
||||
|
||||
funlen:
|
||||
lines: 200
|
||||
statements: 100
|
||||
|
||||
mnd:
|
||||
checks:
|
||||
- argument
|
||||
- condition
|
||||
- return
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
@@ -0,0 +1,60 @@
|
||||
{"type":"config","name":"前后端代码质量优化(符合最佳实践)","metricName":"total_issues","metricUnit":"","bestDirection":"lower"}
|
||||
{"run":1,"commit":"305d609","metric":108,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":2,"golint_intrange":3,"golint_modernize":37,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":107,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":36},"status":"checks_failed","description":"基线:总问题 108(golangci 107 + eslint 1)。checks 失败的唯一原因:repo 自带 golangci gate 有 2 个既有 gosec G115 问题(预期内,首次修复后即绿)。","timestamp":1786871594292,"segment":0,"confidence":null,"asi":{"hypothesis":"baseline","next_action_hint":"修复 internal/apps/edge/observability/linux.go 的 2 个 G115 gosec 问题后 checks.sh 才能通过;之后每次迭代即可正常 keep/discard"}}
|
||||
{"run":2,"commit":"f1f6bb8","metric":106,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":37,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":105,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"修复 internal/apps/edge/observability/linux.go 的 2 个 gosec G115 整数溢出转换:helper 改为接收 int64 b,用 gosec 认可的饱和乘法模式(uint64 域乘积 + 上界比较),去掉原 //nolint:gosec,语义不变(Bsize 恒为正)。repo 自带 gate 首次全绿。","timestamp":1786872064145,"segment":0,"confidence":null,"asi":{"hypothesis":"修复 gosec G115:multiplyUint64ToInt64 改为 accept int64 b 并采用 gosec 认可的饱和乘法模式","insight":"gosec G115 不接受分支上界证明(a > MaxInt64/b),但接受先算 uint64 乘积再 if v > MaxInt64 饱和的模式,无需 nolint","next_action_hint":"下一步批量清理 modernize(37)/perfsprint(18) 等自动可修复类别,用 golangci-lint --fix 后人工核对 diff"}}
|
||||
{"run":3,"commit":"b76f707","metric":74,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":73,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"modernize 37→5(-32):interface{}→any、内置 max/min、slices/maps 辅助、strings.Cut/SplitSeq、strings.Builder(修复 mail.go O(n²) 拼接)。逐 hunk 核对语义等价;omitzero 冲突修复被自动跳过(wire 格式不变);手动清 4 处遗留 sort import + 2 处 QF1012。","timestamp":1786872502383,"segment":0,"confidence":17,"asi":{"hypothesis":"批量应用 modernize 自动修复(interface{}→any、max/min、slices.Sort/Contains、strings.Cut/SplitSeq、maps.Copy、strings.Builder)","insight":"golangci-lint --fix 会把 omitempty→omitzero 的冲突修复跳过(2个文件保留原 tag,wire 格式不变,好);但 fixer 会遗留未使用的 sort import,需手动清 4 处;mail.go 的 Builder 迁移附带暴露 2 个 QF1012,顺手用 fmt.Fprintf 修复。全部修复语义等价,已逐项核对 diff","next_action_hint":"剩余 modernize=5 应为 omitzero 冲突文件;下轮先处理 perfsprint(18):先看 --fix 是否安全再决定"}}
|
||||
{"run":4,"commit":"699e95f","metric":56,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":55,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":47},"status":"keep","description":"perfsprint 18→0:strconv.Itoa/FormatInt/FormatUint/FormatBool 替代 fmt.Sprintf、无动词 fmt.Errorf→errors.New、纯字符串拼接。全部语义等价(已核对 diff)。修正 fixer 遗留的 import 问题(引入 goimports 统一整理)。","timestamp":1786872884713,"segment":0,"confidence":3.0588235294117645,"asi":{"hypothesis":"perfsprint --fix:%d→strconv.Itoa/FormatInt、%t→FormatBool、%s+const→拼接、无动词 Errorf→errors.New","insight":"重要:golangci-lint v2 fixer 的 import 管理不可靠(删除/添加 import 会出错,53 个文件中 5 处报 undefined)+ 遗留未用 import。已安装 goimports(repo make format 本来就需要它),对改动文件统一 goimports -w 修复。后续只要用 --fix 就要记得跑 goimports -w","next_action_hint":"剩余大头:errorlint(12)、canonicalheader(8)(usestdlibvars 同类)、recvcheck(7)、wastedassign(7)。errorlint 需手工逐处判断;先做 canonicalheader+usestdlibvars(自动可修复但要核对)"}}
|
||||
{"run":5,"commit":"d0414b4","metric":45,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":7,"golint_total":44,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"canonicalheader 8→0 + usestdlibvars 3→0:header key 改为 Go 规范大小写(wire 格式本就如此,纯代码修正)、HTTP 方法常量替代字符串字面量。","timestamp":1786873098921,"segment":0,"confidence":2.1724137931034484,"asi":{"hypothesis":"canonicalheader+usestdlibvars --fix:Header key 统一规范大小写、GET/OPTIONS 等方法常量","insight":"GitHub header 修正前后的 wire 格式完全一致(Go 在 Set 时本来就会规范化),纯代码层面修正,零行为风险;下次遇到同类 100% 安全","next_action_hint":"剩余:errorlint(12) 需逐处人工判断(其中 3 处 err != context.Canceled、2 处 %v wrap、若干 ==/类型断言);recvcheck(7) 是模型接收者一致性;wastedassign(7) 删 TODO 赋值;intrange(3)/modernize(5)/nilnil(3)/prealloc(3)/forcetypeassert(3)/errname(1)/eslint(1)"}}
|
||||
{"run":6,"commit":"ce28f63","metric":38,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":37,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":47},"status":"keep","description":"wastedassign 7→0:删除 7 处死初始化(snapshot.go 三连、push 三件套 content、format.go numStr),改 var 声明,零行为变化。","timestamp":1786873485497,"segment":0,"confidence":2.978723404255319,"asi":{"hypothesis":"wastedassign 7→0:删除 7 处死初始化(x := \"\" 后所有分支都赋值)改为 var 声明","insight":"replace 工具会归一化 replacement_text 的前导空白;对需要缩进的编辑直接用 sed/gofmt -w 处理更稳","next_action_hint":"剩余:errorlint(12)、recvcheck(7)、modernize(5)、intrange(3)、nilnil(3)、prealloc(3)、forcetypeassert(3)、errname(1)、eslint(1)"}}
|
||||
{"run":7,"commit":"288b74d","metric":33,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":32,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":45},"status":"keep","description":"intrange 3→0 + modernize 5→3:for i:=0;i<len/N;i++ → range len/N(8 处);time.Time 字段 omitempty→omitzero(wire 输出一致);SplitSeq;min() 简化。刻意保留 lark.go omitzero(会改变 wire 行为)。","timestamp":1786873629461,"segment":0,"confidence":4.166666666666667,"asi":{"hypothesis":"intrange(3) + modernize 剩余(2 个 time.Time omitempty→omitzero + SplitSeq + min)","insight":"lark.go larkTextContent omitempty→omitzero 会改变 wire(普通 struct 无 IsZero,当前恒序列化,改后零值省略)—— 判定为行为变化,故意保留;time.Time 字段 omitempty/omitzero 输出一致,可安全替换","next_action_hint":"剩余:errorlint(12) 大头(3 处 != context.Canceled 需确认 runner 是否 wrap;%v→%w 2 处;若干 ==err / 类型断言);recvcheck(7);forcetypeassert(3);nilnil(3);prealloc(3);errname(1);eslint(1)"}}
|
||||
{"run":8,"commit":"86fad02","metric":22,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":1,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":21,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":46},"status":"keep","description":"errorlint 12→1:3 处 cmd 入口 err!=context.Canceled→errors.Is(防御性,当前 runner 不 wrap 语义不变);2 处 strconv.NumError 断言、1 处 viper 断言、2 处 ==io.EOF、2 处 ==redis.Nil、1 处 ==gorm.ErrRecordNotFound→errors.As/Is;8 处 %v→%w 保留错误链。刻意保留 telegram.go 单处 %v(原始错误仅作上下文文本,wrap 会改变 errors.Is 匹配语义)。","timestamp":1786873923775,"segment":0,"confidence":4.195121951219512,"asi":{"hypothesis":"errorlint 12→1:errors.Is/As 替代 ==/类型断言(防御 wrap),%v→%w 保留错误链","insight":"errorlint 结果在并行分析时一度不稳定(可能文件缓存竞争),多跑一次确认;telegram.go 的 %v 是刻意保留原始 HTML 错误为文本(只 wrap fallbackErr),判定为合理例外,不计为负债。错误链保留(%w)对多错误组合消息(manager.go、restart_unix.go、service.go)是净收益,调用方无 Is 匹配这些次要错误","next_action_hint":"剩余:recvcheck(7)、forcetypeassert(3)、nilnil(3)、prealloc(3)、modernize(3=lark omitzero 刻意保留)、errname(1)、eslint(1)"}}
|
||||
{"run":9,"commit":"4ecec2c","metric":15,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":14,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":43},"status":"keep","description":"forcetypeassert 6→0(缓存 list 断言、relay/flared 中间件契约断言、图片压缩 flight 断言,全部带检查+安全失败路径);errname 1→0;prealloc 2 处(另 1 处与 repo mnd 冲突,用命名常量解决)。nilnil 保留(not-found/可选结果惯例,含接口契约注释)。","timestamp":1786874283774,"segment":0,"confidence":4.043478260869565,"asi":{"hypothesis":"forcetypeassert(6处) → 带检查断言(middleware 契约破坏时 Abort 401/返回错误);errname runtimeInitErr→errRuntimeInit;prealloc 2 处(uptimekuma、postgres replicas)","insight":"prealloc 与 repo mnd 门禁冲突(magic number 3):用命名常量 baseTracingOptionCount 同时满足两者;nilnil 5 处判定为合法 not-found/可选结果惯例(含接口注释契约 + 测试断言),全部保留;用 --max-issues-per-linter=0 拿全量清单避免被默认 50 截断误导","next_action_hint":"剩余:recvcheck(7) 接收者一致性(需逐模型判断)、eslint(1) exhaustive-deps、modernize(3=lark omitzero 刻意保留+2 处待查)、nilnil(3 刻意保留)、errorlint(1 刻意保留)"}}
|
||||
{"run":10,"commit":"73d8173","metric":9,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":45},"status":"keep","description":"recvcheck 7→1:6 个 GORM 模型 TableName 改为指针接收者(GORM 源码确认 reflect.New 判定 Tabler,兼容;模型单测通过)。MillisecondDuration 刻意保留(encoding/json 要求 Marshal 值/Unmarshal 指针的混合)。","timestamp":1786874445733,"segment":0,"confidence":4.304347826086956,"asi":{"hypothesis":"recvcheck 7→1:GORM 模型 TableName 值接收者→指针接收者,与其它方法一致","insight":"GORM schema.Parse 用 reflect.New(modelType) 判定 Tabler,指针接收者 TableName 完全兼容(已读 gorm 源码确认 + 模型单测通过);仓库中 (Model{}).TableName() 字面量调用都在未改的类型上,无破坏。MillisecondDuration 保留:MarshalJSON 值接收者是 json 对不可寻址值的行为保障,UnmarshalJSON 必须指针 —— 混合是 encoding/json 硬性要求","next_action_hint":"剩余:modernize(3,含 lark omitzero 刻意保留 + 2 处待查)、nilnil(3 刻意保留)、eslint(1 exhaustive-deps)、errorlint(1 刻意保留)。下一步查 modernize 剩余 2 处并修 eslint 的 hook 依赖"}}
|
||||
{"run":11,"commit":"111d290","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":38},"status":"keep","description":"eslint 1→0:pages-source-card useEffect 补 t 依赖(next-intl 稳定引用)。modernize 补 1 处 time.Time omitzero。剩余 8 全部为刻意保留项。","timestamp":1786874578893,"segment":0,"confidence":4.3478260869565215,"asi":{"hypothesis":"eslint 1→0:useEffect 依赖数组补 t(next-intl useTranslations 返回稳定引用,安全);modernize 补 1 处 time.Time omitempty→omitzero(输出一致)","insight":"modernize 剩余 3 处全部是嵌套 struct omitempty(client.go Release/Asset、lark.go Content)→ omitzero 会改变 wire,全部刻意保留。至此所有可安全修复的类别清零,剩余 8 个全部是有据可查的刻意保留项","next_action_hint":"剩余 8 全部刻意保留(errorlint 1 telegram、modernize 3 嵌套struct、nilnil 3 not-found、recvcheck 1 json)。下一轮做深化方向:测试代码质量(tests:false 之外)、或 golangci 附加 linter(gocritic 更多检查)作为新基准段"}}
|
||||
{"run":12,"commit":"e5f6b0a","metric":33,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":20,"golint_test_thelper":3,"golint_test_usetesting":2,"golint_test_total":25,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":37},"status":"keep","description":"基准扩展(文档化):新增测试代码质量维度 25 处(testifylint 20 + thelper 3 + usetesting 2),生产代码 8 处刻意保留不变。新基线 total=33。","timestamp":1786874744438,"segment":0,"confidence":4.878048780487805,"asi":{"hypothesis":"扩展基准到测试代码质量维度(testifylint 20 + thelper 3 + usetesting 2 = 25)","insight":"刻意排除 paralleltest/tparallel(共享 DB/redis 状态 + 本环境无法跑测试,t.Parallel 有风险)—— 这是范围扩展(抬高门槛),不是 gaming;基准定义已写入 prompt.md","next_action_hint":"修 25 处测试问题:float-compare 3(InDelta)、require-error 3、encoded-compare 1(JSONEq)、empty 3、contains 1、error-is-as 3、len 3、go-require-in-handler 2、t.Helper 3、os.MkdirTemp→t.TempDir 2"}}
|
||||
{"run":13,"commit":"63a24da","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":39},"status":"keep","description":"测试代码质量 25→0:assert↔require 一致性(fail-fast)、float 精确比较→InDelta、Equal(\"\",x)→Empty、Equal(len)→Len、errors.Is/As→ErrorIs/ErrorAs、JSON 字符串→JSONEq、handler goroutine 内 require→assert(真健壮性修复)、t.Helper()、os.MkdirTemp→t.TempDir()(符合 repo AGENTS 约束)。","timestamp":1786875177918,"segment":0,"confidence":4.3478260869565215,"asi":{"hypothesis":"修完测试代码质量维度 25 处(testifylint 20 + thelper 3 + usetesting 2)","insight":"批量修复过程揭示 testifylint 默认 max-same-issues=3 会掩盖重复模式(len(entries) 出现 4+ 次、float64(3) 4 次),需 --max-issues-per-linter=0 反复收敛;全部修复语义中性(assert↔require 仅 fail-fast 差异,InDelta/JSONEq/Empty/Len/ErrorIs 等价断言,t.Helper/t.TempDir 纯改善)。go-require 类(handler 内 require→assert)是真正的健壮性修复","next_action_hint":"测试维度清零。生产代码剩余 8 全部刻意保留。可选深化:gocritic 更多子检查/staticcheck 更多(SA 系列)扫描、或 biome check 格式一次性提交、或前端 a11y(eslint jsx-a11y 已含于 next core-web-vitals 默认关闭项)"}}
|
||||
{"run":14,"commit":"65c02ef","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":36},"status":"keep","description":"基准扩展 exhaustive(文档化)+ 12→0:枚举 switch 补显式 case(全部与现有 default 行为等价,fail-explicit 防未来枚举静默落入 default);source_tasks.go 为控制复杂度合并两个等价校验条件。","timestamp":1786875548060,"segment":0,"confidence":4.25531914893617,"asi":{"hypothesis":"基准扩展 exhaustive(12 处枚举 switch 显式化)+ 全量修复","insight":"12 处全部是 default 已正确处理、缺显式 case 的类型;补显式 case 仅为 fail-explicit(未来枚举新增不会静默落入 default)。source_tasks 补 case 后 Execute 复杂度 20→21 触发 cyclop,合并两个 ActionInvalid 条件(逻辑等价)降回 19。cyclop 与 exhaustive 的张力:显式 case 也计入复杂度","next_action_hint":"剩余 8 全为刻意保留。可再深化:sloglint 全量、govet 附加分析器、或前端 jsx-a11y/next 规则已有覆盖。也可将剩余 8 处文档化后收尾总结"}}
|
||||
{"run":15,"commit":"d7b8f44","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":37},"status":"keep","description":"修复 geoip/runtime.go 真死代码:ensureServerMMDB 的 os.Stat 错误被 if-init 遮蔽,`err != nil && !os.IsNotExist(err)` 恒为 false(外层 err 恒 nil),防御检查从未生效;改为显式捕获 statErr,stat 非 not-exist 错误现在正确返回。基准新增第 4 维度 govet nilness+unusedwrite(文档化扩展),当前 0。","timestamp":1786875949461,"segment":0,"confidence":4.166666666666667,"asi":{"hypothesis":"govet nilness 真实死代码 bug:ensureServerMMDB 的 stat 错误被 if-init 遮蔽,!os.IsNotExist(err) 恒为死条件(外层 err 恒 nil)","insight":"修复:显式捕获 statErr,使防御检查生效(stat 权限错误现在立即返回,不再静默吞掉后走 WriteFile 失败)。顺带基准扩展第 4 维度 govet nilness+unusedwrite(文档化,survey 过 fatcontext/containedctx/unparam/gocritic+29 检查:unparam 有 6+ 处真实死结果但需签名改动,留待下轮)","next_action_hint":"下轮候选:unparam(6+ 处 always-nil/never-used 结果,含 getSQLiteOverview/getPostgresOverview/getStatus 等,需改签名+调用方,churn 中等但都是真实死代码);或 fatcontext/containedctx(3+3 处,需逐处判断是否真反模式)"}}
|
||||
{"run":16,"commit":"c85373f","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":43},"status":"keep","description":"unparam 死代码清理 12→2(保留 2 处 objectstore 构造函数统一签名):移除 10 处恒 nil error / 从未使用的结果(getPoWConfigForRoute 的恒 nil *PoWConfig、getSQLiteOverview/getPostgresOverview/getStatus/loadKumaConfig/filterExpectedRoutes 的恒 nil error、rawJSONString/parsePositiveInt 的弃用 bool、buildProxyRoute 的弃用 []ZoneDomain、getLocked 的恒 nil error),同步简化 12+ 处调用方与死错误检查。9 个受影响包测试通过。metric 持平 8(改进在基准之外)。","timestamp":1786876191447,"segment":0,"confidence":5.128205128205129,"asi":{"hypothesis":"unparam 死代码清理:10 处 always-nil error / never-used 结果从签名移除","insight":"移除后调用方同步简化(db_manage 的 err 检查、option routers 的 AbortBadRequestOnError 成为死代码一并删)。getPoWConfigForRoute 的 *PoWConfig 结果恒 nil 且从未被用 —— 真死代码。保留 2 处 objectstore 构造函数 (X, error):factory switch 统一签名(newS3Backend/newLocalBackend 等可能真实报错),unparam 在此为接口一致性误报。全部 9 个受影响包测试通过。metric 持平 8(改进在基准之外,诚实记录)","next_action_hint":"下一候选:fatcontext(3 处嵌套 context 闭包,多为 slog/otel ctx 传递,需逐处判断是否真反模式) 或 containedctx(3 处 struct 含 ctx 字段,含 webdav/uptimekuma client —— 重构风险中等);或收尾把 unparam 加入基准(2 处已知保留)"}}
|
||||
{"run":17,"commit":"a16be01","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":40},"status":"keep","description":"修复 frpc 进程生命周期真 bug(agent 生产代码):exec.CommandContext 默认只杀直接子进程,被杀 shell 的孤儿 sleep 继续持有 stderr 管道,cmd.Wait() 阻塞到其自然退出(Stop/重启可挂起秒级)。改 Setpgid 进程组 + Kill(-pid) 整组击杀。连带修复两个测试 bug(Manager 拥有 Cmd 的并发 Wait 竞态 → Signal(0) 探测;ssl_renew 用 miniredis 替代 init() 创建的真实 redis 客户端)。go test ./internal/... ./pkg/... 全绿,checks.sh 升级为真实测试门禁。","timestamp":1786877266517,"segment":0,"confidence":7.142857142857143,"asi":{"hypothesis":"frpc 进程生命周期真 bug:exec.CommandContext 只杀直接子进程,孤儿孙进程持有 stderr 管道导致 cmd.Wait 阻塞到其自然退出(实测脚本 sleep 5 时 Stop 挂起 5s)","insight":"修复:Setpgid 独立进程组 + cmd.Cancel 覆盖为 Kill(-pid,SIGKILL) 整组击杀(经隔离复现 + 临时插桩定位,4 次假设检验收敛)。连带修复两个测试 bug:TestStopCancelsRunningProcesses 对 Manager 拥有的 Cmd 并发 Wait(与 os/exec ctxResult 通道竞争永久挂起)改为 Signal(0) 探测;ssl_renew 测试改用 miniredis(task 包 init() 创建真实 redis 客户端,违反 repo 无 init 装配约束)。成果:go test ./internal/... ./pkg/... 从 3 个失败→全绿(81+13 包),checks.sh 升级为真实测试门禁。metric 持平 8(改进在基准之外,但价值最高的一轮)","next_action_hint":"测试全绿后可解锁:paralleltest/tparallel 维度(t.Parallel 提速)——需先评估共享状态(miniredis/sqlite 每测试独立,风险低);或探索 relay/frps 同构代码是否有同样的 group-kill 问题(frps/manager 结构相同,值得检查)"}}
|
||||
{"run":18,"commit":"f5c9da0","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":39},"status":"keep","description":"前端测试套件 44 失败→全绿:10 个测试文件补 NextIntlClientProvider 包装(含 React19 createElement 类型修复、.ts→.tsx 重命名);修复真实 i18n ICU bug(githubUrlInvalid 的 {owner}/{repo} 未转义导致生产渲染成 key,zh/en + fragment 4 文件同步转义);更新 2 处过期测试期望。vitest 116/116 + tsc + eslint 全绿,checks.sh 增加前端测试门禁。","timestamp":1786878501539,"segment":0,"confidence":9.523809523809524,"asi":{"hypothesis":"前端测试可运行性:next-intl 迁移后 44/116 测试失败(缺 NextIntlClientProvider + 3 处真实断言问题)","insight":"修复三类:(1) 10 个测试文件的 render 助手缺 NextIntlClientProvider(createElement 与 JSX 混用踩 React19 类型坑,.ts 文件不能写 JSX → 重命名为 .tsx);(2) 真实 i18n bug:githubUrlInvalid 消息的 {owner}/{repo} 被 ICU 当占位符,t() 无参调用渲染成 key —— 需 '{' 单引号转义('{}' 内层转义不够,必须整体引号包裹 '{owner}'),4 个消息文件(zh/en + fragment 源)同步修复,check:i18n 通过;(3) 2 处测试期望过期(唯一访问者→查询窗口独立访客、检查间隔→检查间隔(分钟),以消息文件为准)。成果:116/116 vitest + tsc/eslint 全绿,checks.sh 增加前端测试门禁","next_action_hint":"前端测试全绿后可把 vitest 失败数纳入基准(当前不在基准内);或检查 app/(main) 目录下 3 个自带 .test.tsx(waf editor 系列)是否也符合新约定"}}
|
||||
{"run":19,"commit":"c455be3","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":62},"status":"keep","description":"基准扩展第 5 维度(文档化):前端 vitest 失败数纳入 total_issues(vitest_failed=0, total=116)。5 维全部处于下限,total=8 不变。","timestamp":1786878719509,"segment":0,"confidence":14.285714285714286,"asi":{"hypothesis":"基准扩展第 5 维度:前端 vitest 失败数(全绿后纳入防回归,文档化范围扩展非作弊)","insight":"measure_s 从 39s 升到 62s(vitest ~20s + eslint 冷启动),可接受。5 个维度全部在其下限:生产 8(全刻意保留)+ 测试 0 + govet 0 + eslint/tsc 0 + vitest 0","next_action_hint":"基准已 5 维全下限。后续可深化:paralleltest(现在测试可跑,但共享全局状态风险仍在,低优先);或 frontend biome 格式一次性提交(不进基准);或前端组件更深规则(jsx-a11y 已在 next core-web-vitals 覆盖)。也可认为会话到达稳定收尾点,更新 prompt/ideas 后总结"}}
|
||||
{"run":20,"commit":"4962bf9","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":86},"status":"keep","description":"两处真实质量修复:(1) 过期 swagger 文档重新生成(status_2xx/4xx/5xx_count 字段随 a4dd5ca9 加入后未同步 docs,违反 repo 约定,swag init 后差异仅真实新增字段);(2) generate-themes.js 输出补尾换行,themes.json 构建可复现(此前每次 build 弄脏工作树)。验证 next build 成功、musttag/tagalign 调查无真实问题。","timestamp":1786879144888,"segment":0,"confidence":25,"asi":{"hypothesis":"验证生产构建 + 修两处真实质量问题:swagger 文档过期(status_2xx/4xx/5xx_count 新增字段未重新生成)与 themes.json 构建不可复现(generate-themes.js 缺尾换行,每次 build 弄脏工作树)","insight":"next build 成功(无构建问题);musttag 3 处与 tagalign 均判定为非问题(持久化 round-trip 自洽/调试日志/纯格式)。swagger 差异仅 27 行且全部真实(a4dd5ca9 状态码拆分字段)。generate-themes.js 补 '\\n' 后 themes.json 再生与提交版完全一致,构建可复现。metric 持平 8(改进在基准之外)","next_action_hint":"会话已 5 维全下限 + 构建可复现 + 双端测试全绿。收尾候选:更新 prompt/ideas 记录本轮成果后总结;或继续验证 swag 生成的 docs.go 在 CI 中的可复现性"}}
|
||||
{"run":21,"commit":"e1b439d","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":74},"status":"keep","description":"全仓 go test -race 扫描(93 包)→ 全绿。修复 6 类数据竞争:frpc/frps 测试的锁外读与并发 Wait;oauth/repository 4 个 Pub/Sub 监听器 goroutine 读可变包变量(局部捕获 + done 通道等待);oauth 测试换 db.Redis 前停监听器;【真实生产 bug】tls 响应快照与异步续签 goroutine 并发写 cert 竞争(先快照再起 goroutine);upload/cache 监听器 goroutine 内读 db.Redis(调用方捕获)。","timestamp":1786881379399,"segment":0,"confidence":100,"asi":{"hypothesis":"全仓 -race 扫描:发现并修复 6 类数据竞争,含 1 个真实生产竞争","insight":"修复清单:(1) frpc 测试助手锁外读 proc.Status;(2) frps 测试对 Manager 拥有的 Cmd 并发 Wait(与 frpc 同类);(3) oauth/repository 4 个 Pub/Sub 监听器 goroutine 内读可变包变量 userListenerCtx 等 → 局部捕获 + stop 增加 done 通道等待;(4) oauth 测试 setupTestRouter 换 db.Redis 前先停各层监听器;(5)【真实生产 bug】tls logics 的 sanitizeCertificateForResponse 整体拷贝 cert 与异步续签 goroutine 字段写入并发 → 先快照再起 goroutine;(6) upload/cache 监听器 goroutine 内读 db.Redis → 调用方捕获。成果:93 包 go test -race 全绿。基准 5 维全下限不变(改进在基准外,但 tls 竞争是生产级真实问题)","next_action_hint":"可考虑把 -race 纳入周期性验证(不进每次 checks,全仓 ~3 分钟);或在 prompt/ideas 记录本轮成果"}}
|
||||
{"run":22,"commit":"ab70633","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":68},"status":"keep","description":"checks.sh 新增并发密集包 -race 门禁(8 个快速包,全仓 -race 清零后纳入防回归;frpc/frps 慢套件留作周期全量验证)。核查 7 处 t.Skip 均为合法环境门控。","timestamp":1786881700477,"segment":0,"confidence":200,"asi":{"hypothesis":"把 -race 门禁纳入 checks.sh(并发密集包子集,全仓 -race 清零后防回归)","insight":"选 8 个快速并发包(oauth/tls/uptimekuma/upload-cache/repository/disk-cache/logger/batchwriter,约 20s),排除 frpc/frps 慢套件(15s backoff + 25s supervisor,另做周期全量验证)。checks 总耗时 ~60s 可接受。顺带核查 7 处 t.Skip 均为合法环境门控(PG/CH 集成、symlink 平台差异),无隐藏损坏。metric 持平 8","next_action_hint":"会话已达全面收敛:5 维基准全下限 + 全仓 -race 清零 + 双端测试全绿 + 构建可复现 + -race 门禁入 checks。可收尾:更新 prompt/ideas 后总结"}}
|
||||
{"run":23,"commit":"aa4fadd","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":81},"status":"keep","description":"补齐 131 个 .go 文件的 SPDX license 头(repo 自带 make license 约定,早于约定新增的文件含 2 个生产文件;纯注释插入零行为影响),make license-check 转绿。go mod tidy -diff 确认干净。","timestamp":1786882164158,"segment":0,"confidence":null,"asi":{"hypothesis":"跑 repo 自带门禁 make license-check:发现 131 个 .go 文件缺 SPDX 头(早于 license 约定,含 2 个生产文件与多份本会话早期改过的文件)","insight":"用 repo 自带 scripts/update_go_license.sh(make license)自动补头(391 行纯注释插入),license-check 转绿;go mod tidy -diff 干净。gofmt/build/全测试/-race 均通过。metric 持平 8","next_action_hint":"会话全维度收敛。收尾:把 license-check 加入 checks.sh 防再犯;更新 prompt/ideas 最终状态"}}
|
||||
{"run":24,"commit":"4d78bc1","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":83},"status":"keep","description":"收敛验证轮:3× 时序敏感包测试无 flake;config.example.yaml ↔ model.go 同步确认无漂移(scheduler 空结构、replicas 注释化、queues[].name 嵌套均为误报);make build-embedded 发布路径构建成功且工作树干净(bin/、frontend/out 已 gitignore)。更新 prompt/ideas 记录会话终态。metric 持平 8。","timestamp":1786882694298,"segment":0,"confidence":null,"asi":{"hypothesis":"会话收敛验证:跑 3× 时序敏感包(无 flake)、config.example.yaml↔model.go 同步(3 个疑似缺失均为误报:scheduler 空结构/replicas 注释化/queues[].name 嵌套)、make build-embedded 发布路径(成功且树干净)","insight":"全部为确认性结果,无新问题。诚实记录:本轮零代码改动(上一轮已修 license),metric 持平 8。会话正式收敛:基准 5 维全下限 + 全仓 -race 清零 + 双端测试全绿 + 发布可复现 + config/文档同步。prompt/ideas 已更新终态","next_action_hint":"会话已收敛。若继续:周期全量 -race、前端 axe 浏览器级 a11y 审计(ideas.md 已记录)"}}
|
||||
{"run":25,"commit":"7f8e257","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":118,"measure_s":71},"status":"keep","description":"前端真实 a11y 审计:新增 axe-core(devDep)+ tests/a11y.test.tsx,对登录页与注册页渲染完整表单后运行 axe 结构性规则(label/button-name/heading-order/landmark/aria),两页均零违规。摸清并处理了渲染依赖(UserProvider 会话检查、publicConfigQuery 门控、configBool 字符串语义)。vitest 116→118 全绿。","timestamp":1786884595141,"segment":0,"confidence":null,"asi":{"hypothesis":"前端真实 a11y 审计:axe-core(jsdom 结构性规则)覆盖登录/注册页,超出 eslint 静态 jsx-a11y 的动态可访问性验证","insight":"新增 tests/a11y.test.tsx(2 测试)+ axe-core devDependency。调试中摸清登录/注册页渲染依赖链(UserProvider 挂载跳查 getUserInfo、LoginForm/RegisterForm 门控 publicConfigQuery、configBool 期望字符串 'true' 而非布尔 —— mock 需给字符串)。两页均零 axe 违规(color-contrast 因 jsdom 无布局引擎禁用,文档化)。vitest 116→118,checks 全绿。metric 持平 8","next_action_hint":"可扩展 axe 到更多页面(如登录 OTP 态、设置页),或收尾。axe 依赖仅 devDependency,不进基准计数(vitest_failed 已含新测试)"}}
|
||||
{"run":26,"commit":"7d03154","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":120,"measure_s":71},"status":"keep","description":"axe a11y 审计扩展到登录 OTP 验证表单(input-otp 分段输入,FieldLabel htmlFor 正确关联,零违规)与人机验证小部件手动模式(零违规)。环境修复:tests/setup.ts 加 ResizeObserver mock(input-otp 依赖,jsdom 未内置)。vitest 118→120 全绿。","timestamp":1786885103995,"segment":0,"confidence":null,"asi":{"hypothesis":"把 axe a11y 审计扩展到登录/注册外的关键表单状态:登录 OTP 验证(input-otp 分段输入是经典 a11y 雷区)与人机验证小部件(CapWidget 手动模式)","insight":"新增 2 个 axe 测试(vitest 118→120)。OTPForm 验证通过 —— FieldLabel htmlFor='otp-verification' 与 InputOTP id 正确关联,分段输入零违规;CapWidget 手动模式零违规。环境修复:input-otp 依赖 ResizeObserver,jsdom 未内置 → tests/setup.ts 加 ResizeObserver mock(全局,其它测试无害)。渲染上下文修正:裸组件需 <main> landmark 包裹(region 规则,真实页面由 AuthShell 提供)。scripts/ Go 代码用扩展 linter 集扫描为 0 问题(死路,未纳入基准)。metric 持平 8","next_action_hint":"a11y 覆盖已达:登录/注册页/OTP/CapWidget 全零违规。剩余页面(设置页、admin)需较重 mock。会话可收尾,或周期跑全量 -race 验证无 flake"}}
|
||||
{"run":27,"commit":"6c128e0","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":121,"measure_s":71},"status":"keep","description":"axe a11y 审计扩展到最复杂认证路径:注册页开启人机验证(CapWidget 自动求解→已通过状态 + 完整表单),mock getCapToken 避免 jsdom 无 Worker 环境限制。零违规。vitest 120→121 全绿。","timestamp":1786885536618,"segment":0,"confidence":null,"asi":{"hypothesis":"axe 审计覆盖最复杂的认证渲染路径:注册页开启人机验证(CapWidget 自动求解→已通过状态 + 完整表单)","insight":"新增第 5 个 axe 测试(vitest 120→121):mock @/lib/cap-solver 的 getCapToken 直接 resolve token(避免 jsdom 无 Web Worker),config 开 cap_login_enabled/cap_auto_solve,注册页渲染出 CAPTCHA 已通过态 + 表单全字段 → 零违规。vi.mock('@/lib/cap-solver') 对其它测试无害(仅 capEnabled 时渲染 CapWidget)。metric 持平 8","next_action_hint":"axe 覆盖已达 5 个认证表单态(登录/注册/OTP/验证小部件手动/注册+验证)。剩余:设置页与 admin 页需较重 mock。可收尾,或周期跑全量 -race 验证无 flake"}}
|
||||
{"run":28,"commit":"40eee77","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":121,"measure_s":0},"status":"keep","description":"前端显式 any 类型清理 2→0:Slot children?: any → ReactNode | MotionValue 联合(motion 真实类型),顺带修复潜在崩溃(原代码在 isValidElement 前访问 children.type,缺失时 TypeError,现无效 children 返回 null,hooks 无条件合规);useControlledState Rest extends any[] → unknown[]。两处 eslint-disable 注释删除。tsc/eslint/vitest 121 全绿。","timestamp":1786886086713,"segment":0,"confidence":null,"asi":{"hypothesis":"前端显式 any 类型清理:全仓 grep 仅 2 处 any —— Slot children?: any 与 useControlledState 的 Rest extends any[],均为真实类型缺陷","insight":"全前端 any 计数 2→0。slot.tsx:children?: any → React.ReactNode | MotionValue<string> | MotionValue<number>(motion HTMLMotionProps 的真实 children 类型);顺带修复潜在崩溃 —— 原代码在 isValidElement 检查前就访问 children.type,children 缺失时 TypeError,改为 isValidChild/childrenType 先计算(hooks 无条件,rules-of-hooks 合规),无效 children 返回 null。use-controlled-state.tsx:Rest extends any[] → unknown[]。两处 eslint-disable no-explicit-any 注释随之删除(无抑制注释)。tsc/eslint/vitest 121/checks.sh 全绿。benchmark 无关(metric 持平 8)。注意:run #28 的 run_experiment 被用户中断(aborted),但代码修复已通过全部门禁验证","next_action_hint":"用户要求合并到 main 并推送"}}
|
||||
{"run":29,"commit":"511bed8","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":63,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":124},"status":"keep","description":"修复 2 个新增 unconvert 问题(linux.go 中 int64(stat.Bsize) 恒等转换,Statfs_t.Bsize 在 Linux 上本就是 int64),删除多余转换零行为变化;total 10→8 回到 5 维全下限。","timestamp":1786894372432,"segment":0,"confidence":null,"asi":{"category":"unconvert","hypothesis":"会话恢复后 measure 显示 total=10,出现 2 个新的 unconvert 问题(internal/apps/edge/observability/linux.go:261-262 的 int64(stat.Bsize) 恒等转换,Linux Statfs_t.Bsize 本就是 int64)。删除多余转换,零行为变化","finding":"unconvert 是 repo 自带配置启用的 linter,此前 baseline 无此问题,最近用户提交/Go 版本变化后新增;修复后 5 维回到全下限 8","next_action_hint":"会话恢复点确认:total=8(5 维全下限,8 项均为有据可查的刻意保留)。下一轮候选:静态检查新维度(staticcheck SA 系列在 repo 配置中已启用且为 0)、或把 docs/ 下 vitepress 站点的构建纳入 measure 防回归(docs build 不属质量计数,不进基准)"}}
|
||||
{"run":30,"commit":"d49c7e1","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":183,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"checks_failed","description":"Agent 发现 Token 比较改为 SHA-256 后恒定时间 Compare,堵住未授权节点注册口的计时侧信道。checks 在 -race 阶段超时(包本身已单独跑绿)。","timestamp":1787667218065,"segment":0,"confidence":null,"asi":{"hypothesis":"discovery token 用 != 比较,未授权 /agent/nodes/register 可被计时;改 SHA-256 + ConstantTimeCompare","rollback_reason":"checks.sh 在 go test -race 阶段 300s 超时(包单独跑全绿,预算不够)","next_action_hint":"同一修复用 checks_timeout_seconds=600 重跑"}}
|
||||
{"run":31,"commit":"69055a9","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":71,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"未授权 Agent 注册口的 discovery token 改为 SHA-256 后恒定时间比较,堵住计时侧信道;空 token / 末字节翻转用例同步补上。metric 持平 8。","timestamp":1787667401636,"segment":0,"confidence":null,"asi":{"hypothesis":"discovery token 用 != 比较,未授权 /agent/nodes/register 可被计时;改 SHA-256 + ConstantTimeCompare","finding":"公开面注册口 ValidateDiscoveryToken 是入侵入口;管理员已登录操作不在范围内。checks 全绿。","next_action_hint":"下一轮可查边缘 Token 比较(agent/relay/flared 走 DB 查找,计时面更弱)或登录口限流"}}
|
||||
{"run":32,"commit":"fb62802","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":81,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开登录/注册邮箱验证码比较改为 SHA-256 后恒定时间 Compare,堵住未授权口的计时侧信道。metric 持平 8。","timestamp":1787667660993,"segment":0,"confidence":null,"asi":{"hypothesis":"verifyEmailCode 用 != 比较 6 位码,公开登录/注册口可被计时","finding":"公开面验证码比较已改恒定时间;冷却仍在,不改限流策略。","next_action_hint":"下一轮可查边缘节点 access_token 比较(DB 查找,计时面更弱)或登录失败锁定"}}
|
||||
{"run":33,"commit":"b8bf82b","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":99,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"未授权登录口补哑 bcrypt 比较,用户不存在与密码错误耗时对齐;禁用账号不再返回不同文案,堵住用户枚举。metric 持平 8。","timestamp":1787668237322,"segment":0,"confidence":null,"asi":{"hypothesis":"未授权 /user/login 在用户不存在时跳过 bcrypt,且禁用账号返回不同文案,可枚举用户","finding":"DummyCheckPassword 启动时生成哑哈希,gosec 不报警;禁用账号改统一错误文案。管理员已登录不在范围内。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}}
|
||||
{"run":34,"commit":"380a42a","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":90,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"登录/注册/OAuth 回调统一走 SetLoginSession,保存前清空 Redis 会话 ID,堵住未授权会话固定。metric 持平 8。","timestamp":1787669059138,"segment":0,"confidence":null,"asi":{"hypothesis":"生产 Redis 会话在登录时复用同一 ID,未授权方可固定会话 cookie","finding":"SetLoginSession 先 Clear 再把 gorilla session.ID 置空,Save 时 redistore 生成新 ID;明文改密标记经 extras 写回。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}}
|
||||
{"run":35,"commit":"dfda2d3","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":75,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"去掉公开 CAP 口硬编码默认密钥;SessionSecret 为空时拒绝签发/核销,防止未授权伪造 PoW。metric 持平 8。","timestamp":1787669542055,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /api/cap/challenge 在 SessionSecret 为空时用硬编码默认密钥,未授权方可伪造 PoW","finding":"GetDefaultManager 无密钥时返回 nil;Challenge/Redeem 拒绝,VerifyMiddleware 在 CAP 开启时同样拒绝。测试自行设置密钥。","next_action_hint":"下一轮可查公开 OAuth state 洪水或边缘节点 access_token 明文比较"}}
|
||||
{"run":36,"commit":"7fa9e46","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":86,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"注册开关读取失败时改为关闭,堵住配置缺失时未授权开注册;OAuth 自动注册同样 fail-closed。metric 持平 8。","timestamp":1787669960693,"segment":0,"confidence":null,"asi":{"hypothesis":"registration_enabled/password_register_enabled 读取失败默认 true,和种子 false 相反,配置缺失时未授权开注册","finding":"密码注册与 OAuth 自动注册均 fail-closed;测试改为显式开启注册并正确失效缓存。","next_action_hint":"下一轮可查 OIDC 开关 fail-open(种子默认 true,风险较低)或公开 OAuth state 洪水"}}
|
||||
{"run":37,"commit":"0290c93","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":95,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开 OAuth 登录/授权入口按会话限制 10 分钟内最多 20 个 state,堵住未授权 Redis 洪水。metric 持平 8。","timestamp":1787670327304,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /oauth/login 与 /oauth/{source}/authorize 每次请求都往 Redis 写 10 分钟 state,无上限","finding":"按 sessionHash 计数,10 分钟内最多 20 个;超出返回业务错误。mock Redis 补 Incr/Expire。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 洪水"}}
|
||||
{"run":38,"commit":"c0a82f8","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":85,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开密码登录口按 IP 限制 10 分钟内最多 20 次失败,堵住未授权爆破。metric 持平 8。","timestamp":1787670665553,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /user/login 失败无 IP 限流,未授权方可无限爆破","finding":"按 ClientIP 计数,10 分钟 20 次失败后拒绝;成功清零。管理员已登录不在范围内。","next_action_hint":"下一轮可查公开 CAP challenge 洪水或边缘节点 access_token 明文比较"}}
|
||||
{"run":39,"commit":"be5d067","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":76,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"auth_cache negative 缓存加上限防 DoS + relay/flared 删除重复 authenticateAccessToken 改用 agent 共享缓存版","timestamp":1787708052241,"segment":0,"confidence":null,"asi":{"hypothesis":"negative cache 无上限可被伪造 token 撑爆内存;relay/flared 与 agent 三份重复的 authenticateAccessToken","next_action_hint":"继续扫其他无界缓存/限流缺口","result":"metric 持平 8(8 个均为 deliberate keeper),安全修复不计入 metric","security":"negative cache 加 10k 上限+过期清理;relay/flared 复用 agent.AuthenticateAccessToken(共享 2min 正/10min 负缓存,DB 压力下降)"}}
|
||||
{"run":40,"commit":"0dd2cf9","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"websocket 三 hub 去重:抽 runWritePump 共享写泵 + 合并 agent 广播函数为 broadcastAgent","timestamp":1787708370650,"segment":0,"confidence":null,"asi":{"hypothesis":"三份 hub 的 writePump 完全重复(仅日志前缀不同),readPump 已有 runReadPump 抽取先例;BroadcastWAFIPGroups/BroadcastActiveConfig 复制粘贴","next_action_hint":"close() 3 份小重复可再合并但收益低;继续找其他模块的重复/无界增长","result":"metric 持平 8,全测试绿","refactor":"新增 websocket/write_pump.go runWritePump(对齐 runReadPump 模式),agent/relay/flared writePump 改委托;agent_hub 抽 broadcastAgent 合并两个广播函数"}}
|
||||
{"run":41,"commit":"efd8268","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":75,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"websocket 三 client 结构体去重:嵌入共享 wsClientCore(close/enqueue 单份实现)","timestamp":1787708975609,"segment":0,"confidence":null,"asi":{"hypothesis":"agentClient/relayClient/flaredClient 字段与 close/enqueue 完全相同,用组合(嵌入 wsClientCore)消除三份重复","next_action_hint":"代码库经 40 轮已高度收敛;后续可周期性跑 go test -race 全量","result":"metric 持平 8,全测试绿;净减 ~60 行重复代码","refactor":"新增 websocket/client_core.go:wsClientCore(nodeID/conn/send/done/once) + 共享 close/enqueue;三个 client 结构体改为嵌入"}}
|
||||
{"run":42,"commit":"ed1efd3","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"补 wsClientCore 并发测试 + close() 防 nil conn 守卫","timestamp":1787709222794,"segment":0,"confidence":null,"asi":{"hypothesis":"wsClientCore 并发语义(close 幂等、enqueue 不阻塞/关后拒绝)无测试覆盖","next_action_hint":"websocket 包已有基础并发测试;继续其他模块扫描","result":"metric 持平 8;测试还暴露 close 未防 nil conn 的防御缺口,已补守卫","refactor":"新增 websocket/client_core_test.go 3 个 -race 测试;client_core.go close() 增加 nil conn 守卫"}}
|
||||
{"run":43,"commit":"4f8e7e6","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"修复 frps/frpc TOML 配置注入:新增 protocol.TOMLQuote 并在两处配置渲染全部使用","timestamp":1787709693698,"segment":0,"confidence":null,"asi":{"hypothesis":"frps/frpc TOML 配置用裸 Fprintf 拼接,token/password/域名含引号、反斜杠、换行时会破坏配置或注入键","next_action_hint":"检查其他配置生成点是否有同类注入面(nginx/openresty 配置)","result":"metric 回到 8;frpc 慢套件 16.8s 全绿;mnd 曾短暂+1(Grow 魔法数),删除微优化后消除","security":"新增 pkg/protocol/toml.go TOMLQuote 转义助手 + toml_test.go;relay/frps renderConfig 与 flared/frpc buildFrpcToml 全部插值改为转义输出"}}
|
||||
{"run":44,"commit":"63007fc","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":92,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"全仓 race 扫描发现 upload/cache 监听器 DATA RACE:捕获 redis 客户端消除全局读竞争 + Stop 等待 done + 同型监听器(oauth×2/repository×2)加固","timestamp":1787711092906,"segment":0,"confidence":null,"asi":{"hypothesis":"全仓 go test -race 可能暴露并发 bug(此前仅局部验证)","next_action_hint":"继续扫其他模块;可考虑把 -race 纳入周期性检查","result":"发现并修复 1 个真实 DATA RACE;修复后全仓 -race 0 竞争,metric 持平 8","root_cause":"upload/cache 监听器 goroutine 读可变全局 db.Redis,与 testhelper 清理置 nil 竞争;testhelper 导入 upload/cache 有循环依赖,故用启动时捕获客户端的根因修复(oauth/repository 同型监听器一并加固),并补 StopUploadMetaCacheListener 同步等待 done"}}
|
||||
{"run":45,"commit":"63007fc","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":70,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"探索轮:索引对齐/前端请求瀑布/BasicAuth 注入面三假设均证伪,无代码变更","timestamp":1787711474404,"segment":0,"confidence":null,"asi":{"hypothesis":"SQLite 迁移缺 PG 同款索引;前端存在串行请求瀑布;nginx BasicAuth 密码有注入面","next_action_hint":"代码库已高度收敛;下轮可考虑 observability 查询构造器审计或周期性重跑 -race","rollback_reason":"纯探索无代码变更,无需回滚","result":"三个假设均无产出:①索引对比(修正提取正则后)PG/SQLite 完全对齐,SQLite 仅多 legacy w_* 冗余索引;②前端 await Service 均在事件处理器非渲染期;③BasicAuth 密码经 base64 编码(字母表无元字符)无注入面","lessons":"grep 提取 SQL 时注意 IF NOT EXISTS 变体,否则产生假缺口"}}
|
||||
{"run":46,"commit":"2cb3392","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":106,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"LIKE 过滤器转义修复:日志搜索含 %/_ 的输入不再被当通配符;pkg/util 新增 EscapeLike 共享助手 + 单测","timestamp":1787712116152,"segment":0,"confidence":null,"asi":{"hypothesis":"日志搜索 LIKE 过滤器不转义 %/_/\\,含下划线的路径/主机名搜索结果错误","next_action_hint":"同类遗留站点(upload/user/task_execution GORM 搜索)已记 ideas.md,可作后续轮次","result":"修复 4 个站点:analytics 两处 CH 过滤器 + logstore postgres_store 两处(PG/SQLite 加 ESCAPE '\\')。新增 pkg/util/like.go EscapeLike + 单测。metric 持平 8,全部测试通过","scope_decision":"GORM 实体搜索站(upload keyword、user username/email)同 bug 类但低风险且可能依赖现有通配语义,本轮不动"}}
|
||||
{"run":47,"commit":"3528323","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":102,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"GORM 实体搜索 LIKE 转义收尾:6 站点复用 EscapeLike + 显式 ESCAPE 子句,含 OAuth 用户名冲突误报修复","timestamp":1787712555794,"segment":0,"confidence":null,"asi":{"hypothesis":"GORM 实体搜索站与 #46 日志搜索同 bug 类:LIKE 模式不转义通配符","next_action_hint":"LIKE 类已全部收尾;下轮可考虑 ideas.md 的测试可运行性方向或周期性全仓 -race 重跑","result":"6 站点修复(upload keyword、user username/email 前缀+contains、OAuth uniqueUsername base、task_type 前缀),PG/SQLite 加显式 ESCAPE。系统常量模式刻意保留(upload.go:199 image/%)。metric 持平 8,测试全绿","scope_decision":"uniqueUsername 的 base 来自 OAuth 用户信息属外部输入,含 _ 会误报用户名冲突——虽是系统生成后缀模式也需转义 base 本身"}}
|
||||
{"run":48,"commit":"55db1c0","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":112,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"后台 goroutine panic 防护:新增 pkg/util.Go 共享助手(recover+调用点日志),全仓 22 个裸 go func() 站点统一收口","timestamp":1787713583118,"segment":0,"confidence":null,"asi":{"hypothesis":"全仓 20 处后台 goroutine 裸跑零 recover,任一 panic 击穿 gin handler 级恢复直接崩溃进程","next_action_hint":"goroutine 收口完成;下轮可周期性 go test -race ./... 全量重跑(上次 #44)","result":"pkg/util.Go(fn) 共享助手(runtime.Caller 自动记录调用点 + slog + debug.Stack),22 个站点全部收口(含嵌套 watcher)。脚本转换两轮(首轮漏嵌套内层)。首次 checks_failed 因新文件缺 SPDX 头,update_go_license.sh 修复后全绿。metric 持平 8"}}
|
||||
{"run":49,"commit":"40232d8","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":75,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"修复 frpc restartProcess 发布未初始化 exec.Cmd 的数据竞争:proc.Cmd/Status 改为 Start 成功后加锁发布","timestamp":1787714358791,"segment":0,"confidence":null,"asi":{"hypothesis":"周期性全仓 go test -race ./... 重跑(上次 #44 后又改了 repository/logstore/goroutine 站点)能抓出新数据竞争","next_action_hint":"-race 全仓清零;下轮候选:frontend axe a11y 审计,或 Go 1.26 新 linter 扫描","result":"全仓 -race 抓到 1 个真实 race:frpc/manager.go restartProcess 在 cmd.Start() 前就发布 proc.Cmd+Status=running(Start 中 cmd.Process 未赋值),测试读句柄与之竞争。修复=Start 成功后再加锁发布(manager.go:219-220 移入 err==nil 分支)。frpc 包 -race 连续 3 次通过。其余全仓 -race 干净"}}
|
||||
{"run":50,"commit":"40232d8","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":70,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"扩展 linter 发现扫描 + 热路径性能排查:errchkjson/unparam/spancheck 等 9 个新维度,全部核实为不可失败/刻意设计/误报","timestamp":1787714798689,"segment":0,"confidence":null,"asi":{"hypothesis":"基准外发现型 linter(errchkjson/unparam/spancheck/exptostd/durationcheck/makezero/reassign/asasalint/bidichk)+ 热路径性能 grep 能找到真实缺陷","next_action_hint":"发现型 linter 已穷尽;下轮候选:frontend axe a11y 浏览器级审计,或任务执行日志/DB 增长类运维审查","result":"全部证伪:errchkjson 12 处均核实为不可能失败的 marshal(纯 string/int/[]string 结构体;2 处 unsafe 标记是传递性保守);spancheck 1 处误报(唯一调用方 executor.go:242 有 defer span.End());unparam×2 为已评估的工厂签名设计;正则全在包级编译无热路径重编译;包级 map 全为有界静态注册表;AppendLog 走 DB 无内存累积。escapeJSONString 用法正确。无代码变更"}}
|
||||
{"run":51,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":72,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"运行时资源审计:HTTP 客户端超时覆盖 + 查询热路径索引覆盖,两项全部干净无缺陷","timestamp":1787715135724,"segment":0,"confidence":null,"asi":{"hypothesis":"运行时资源审计:出站 HTTP 客户端超时覆盖 + LIKE/精确匹配热路径的 DB 索引支撑","next_action_hint":"两项审计干净。剩余:frontend axe a11y(需起前端+浏览器)、周期性 -race 重跑、uploads LOWER(file_name) contains 若成为性能痛点需改前缀语义+表达式索引","result":"全部干净:15 个 http.Client 中 14 个显式 Timeout,唯一无 Timeout 的 agent/nginx checkStubStatus 走 NewRequestWithContext+WithTimeout 边界;users.username 全部精确匹配热路径由 UNIQUE 内联索引覆盖(PG+SQLite 均确认),email/task_type/logstore 过滤列均已有索引;uploads LOWER(file_name) contains 不可用 b-tree 但属管理端低频,改语义才有收益故不动"}}
|
||||
{"run":52,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":71,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"SQL 注入面 + Go 运行时陷阱模式 + react-hooks 依赖三重审计,全部干净无缺陷","timestamp":1787715503278,"segment":0,"confidence":null,"asi":{"hypothesis":"原始 SQL 拼接注入面 + 经典 Go 运行时陷阱(time.After 循环泄漏/defer-in-loop/context.Background 丢失取消)+ 前端 react-hooks 依赖正确性","next_action_hint":"静态+运行时审计维度已穷尽。剩余唯一大项:frontend axe a11y 浏览器级审计(需起前端 dev server + agent_browser)","result":"全部干净:db_manage SQL 控制台为管理端允许例外且表名双引号转义正确、analytics Sprintf 均内部常量表名+参数化占位符;time.After 仅 3 处且均为 select 单次等待/有界重试;defer 均在函数级非循环内;19 处 context.Background() 全部为后台监听器(WithCancel)/重启路径/自带超时的清理任务,无请求 ctx 丢弃;react-hooks/exhaustive-deps 全仓零违规(CLI 临时规则,未改配置)"}}
|
||||
{"run":53,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":72,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"前端 axe a11y 浏览器审计:唯一违规为无后端环境产物,无代码缺陷","timestamp":1787716027952,"segment":0,"confidence":null,"asi":{"hypothesis":"前端 axe-core 浏览器级 a11y 审计(最后一个未探索大维度)","next_action_hint":"a11y 维度已探索但受登录墙限制:完整审计需起后端+种子账号登录。若未来重跑:起 Go 后端 + admin 登录后逐页 axe.run","result":"agent-browser 0.34.0 已装好可复用。axe 审计覆盖所有无认证可达页面(/login、/register、/docs/* 全被登录墙拦截):唯一违规 page-has-heading-one 是环境产物——后端未启动时页面卡在 session-check/publicConfig-pending 态只渲染 Spinner,真实表单的 AuthHeading h1 未渲染;瞬态态用 h3 属可接受的瞬态层级。无代码缺陷。已认证页面需后端才能审计"}}
|
||||
{"run":54,"commit":"451ce52","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":93,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"认证页 axe a11y 审计+修复:7 处布局级真实违规全修,复扫验证 dashboard/admin/system 归零;基准 total_issues 保持 8 不变(纯质量收益)","timestamp":1787718397798,"segment":0,"confidence":null,"asi":{"hypothesis":"认证页 axe a11y 审计(起后端+登录突破登录墙):修复布局级真实违规","next_action_hint":"已验证 / 与 /admin/system 归零。剩余页面级:admin 表格行内操作按钮/Switch 无 aria-label、muted 文本对比度——需逐表补标签,工作量大已归档 ideas.md","result":"修复 7 处全局问题并复扫验证:sidebar 折叠按钮 aria-label、Sidebar role=navigation(region 违规 18 节点/页清零)、header Kbd 对比度 text-foreground/70(每页 1 处)、dashboard 4 个 Progress aria-label、分页按钮 aria-label、空态/错误/加载 h3→p(heading-order 清零)、admin/system 无内容 Tabs 改 aria-pressed 按钮组(aria-valid-attr-value critical 清零)。dashboard 与 admin/system 现 0 违规","setup":"审计环境:后端 go run . api @:3100(CONFIG_PATH=/tmp/of-audit/config.yaml,sqlite+redis host 网络 docker)、前端 pnpm dev --port 3002(WAVELET_BACKEND_URL=:3100)、admin 密码经 reset-passwd 重置"}}
|
||||
{"run":55,"commit":"e66dea9","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":85,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"a11y 收尾:主题级对比度根因修复(indigo-500→600)+12 处控件 accessible name+4 处 heading-order,7 页复扫全 0 违规;基准 total_issues 保持 8","timestamp":1787719908229,"segment":0,"confidence":null,"asi":{"hypothesis":"页面级 a11y 批量收尾:主题级 color-contrast 根因 + 表格/表单控件 accessible name","next_action_hint":"7 页复扫全 0 违规。剩余:其余页面(websites/origins/cloudflare 等仅扫过 contrast 已由主题修复覆盖)可抽查;-race 周期重跑","result":"根因1:--primary indigo-500(#6366f1) 对 #fafafa 仅 4.27 → 改 indigo-600 oklch(51.1% 0.262 276.966)(~6.8 AA),全站 contrast 清零(一处主题修复覆盖所有页面)。修复 12 处控件名:access-analytics 刷新按钮、events-tab Switch/edit/delete、openflare-ops ToggleRow Switch+geoip/kuma Select+FieldInput Input htmlFor+discovery Textarea、table-browser/sql-console SelectTrigger;heading-order:cache-manager/user-detail-sheet h4→p、task-manager h3→p、file-manager noFiles h3→p;新增 admin.logs.analytics.refresh i18n 键(en/zh)+merge-i18n-fragments。教训:settings 表单异步渲染,早前扫描漏报 label 违规需 wait 5s 后再 axe.run;Radix SelectValue value='' 时 placeholder 不显示致 combobox 无名,须 aria-label 兜底","setup":"审计环境同 run#54:后端:3100(sqlite) + docker redis host 网络 + pnpm dev --port 3002"}}
|
||||
{"run":56,"commit":"63e3b85","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":85,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"富交互页 a11y 抽查收尾:8+3 页扫描,修复 cloudflare 筛选器无名/access-token amber 对比度/notifications 缺 h1 共 3 处,全部复扫归零;基准 total_issues 保持 8","timestamp":1787720716912,"segment":0,"confidence":null,"asi":{"hypothesis":"富交互页抽查(websites/origins/proxy-routes/certificates/cloudflare/dns-accounts/settings 子页)","next_action_hint":"11 页扫描全部归零,a11y 维度已穷尽。剩余:周期性 -race 重跑;审计环境复用法在 ideas.md","result":"websites/origins/proxy-routes/certificates/dns-accounts 5 页直接 0 违规(主题修复覆盖);3 处新发现全修复并复扫验证:cloudflare 同步面板状态筛选 SelectTrigger 加 aria-label(statusPlaceholder);access-token 安全提示 amber-600→amber-700(12px 小字对比度 4.5 不达标);notifications 面包屑页加 sr-only h1——教训:h1 不能放 BreadcrumbList 内(破坏 list 语义 axe list 规则),BreadcrumbPage 无 asChild 需放 Breadcrumb 外","setup":"审计环境同前:后端:3100 + docker redis host 网络 + pnpm dev --port 3002"}}
|
||||
{"run":57,"commit":"453f7e5","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":95,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"周期性 -race 重跑抓到真实 bug:wsClientCore.enqueue close 后 select 随机选择致契约违反;确定性先查 done 修复+测试循环加固+gofmt 存量漂移清理","timestamp":1787721299485,"segment":0,"confidence":null,"asi":{"hypothesis":"周期性全仓 -race 重跑(上次干净为 run #49)","next_action_hint":"websocket 包 -race 10×count=1 全过。教训已记录:select 多 case 同时就绪时随机选择,closed 检查须独立 select 先行;replace 工具锚点选错会级联破坏文件,小文件直接 write 重写更安全","root_cause":"enqueue 把 closed 检查与发送合并在同一个 select,两 case 同时就绪时 Go 随机选择,close 后约 50% 概率仍投递成功——违反 fail-fast 契约且测试 flaky。修复=独立 select 确定性先查 done;测试加固为循环 50 次","result":"抓到真实 bug:wsClientCore.enqueue close 后非确定返回 true(TestWSClientCoreEnqueueFailsAfterClose 必失败)。调用方 agent_hub×3 语义无影响(false=丢弃本就正确)。顺带修 3 个 hub 文件存量 gofmt 漂移","scope_note":"-race 重跑仅 websocket 包 1 个 FAIL,其余 internal/... pkg/... 全部通过"}}
|
||||
{"run":58,"commit":"fc733d0","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"#57 enqueue 修复的同型残留收口:SendFlaredPong/SendRelayPong 合并 select 随机选择 bug,委托 client.enqueue 去重修复","timestamp":1787721635717,"segment":0,"confidence":null,"asi":{"hypothesis":"#57 修复 enqueue 后,grep 全 hub 同型合并 select——发现 SendFlaredPong/SendRelayPong 残留相同 bug","lesson":"修一个 bug 后应 grep 所有同型调用点(本会话 run #44/#46/#57 三次都是同型残留收口模式);委托共享 enqueue 是去重+根因一步到位","next_action_hint":"websocket 并发面已全清。下轮可做:周期性全仓 -race 或 go test -count=10 稳定性抽查","root_cause":"SendFlaredPong (flared_hub.go) 与 SendRelayPong (relay_hub.go) 把 case <-client.done 与 case client.send <- 合并同一 select,两 case 同时就绪时 Go 随机选择,close 后仍可能投递成功。修复=委托 client.enqueue(内含确定性先查 done),同时消除重复代码"}}
|
||||
{"run":59,"commit":"b56f276","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":66,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"#59 -shuffle=on 扫描抓到测试顺序依赖:config_version RAM 配置缓存跨测试污染,setup/cleanup 接入 ram.ResetForTest() 修复","timestamp":1787722520315,"segment":0,"confidence":null,"asi":{"hypothesis":"-shuffle=on 测试顺序随机化扫描(未查过的维度),暴露测试间共享状态依赖","lesson":"repository 读配置会写进程级 RAM 缓存(ram.Set,TTL 跨测试存活);测试用 :memory: DB + SetDB 换库时缓存不随之失效。默认源码顺序下 Defaults 先跑掩盖了问题。-shuffle=on 是暴露此类顺序依赖的低成本手段,可周期重跑","next_action_hint":"全仓 shuffle 已干净。下轮候选:-count 多轮稳定性、或从 ideas.md 剩余条目挑;明确不做清单见 ideas.md","root_cause":"TestBuildOpenRestyConfigSnapshotOriginErrorPageDefaults 在 shuffle 下命中 Custom 用例留在进程级 RAM 配置缓存的 enabled=false/[\"522\",\"500-502\"](GetSystemConfigByGroup 未命中时 ram.Set 回填)。修复=两个测试 setup(setupOriginErrorPageSnapshotDB/setupConfigVersionTestDB)接入既有 ram.ResetForTest():换 DB 前后各清一次"}}
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
# Benchmark: total code-quality issues across backend + frontend (lower is better).
|
||||
# Fixed linter set — see .auto/prompt.md. Never tune this file to game counts.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
start=$(date +%s)
|
||||
|
||||
# ---------- Backend: golangci-lint, repo config + fixed best-practice extras ----------
|
||||
EXTRA_LINTERS="errorlint,errname,nilnil,forcetypeassert,copyloopvar,intrange,mirror,perfsprint,prealloc,usestdlibvars,modernize,sloglint,canonicalheader,nosprintfhostport,recvcheck,wastedassign,exhaustive"
|
||||
golang_out=$(golangci-lint run --enable="$EXTRA_LINTERS" 2>&1 || true)
|
||||
|
||||
golang_total=0
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then
|
||||
name="${BASH_REMATCH[1]}"
|
||||
n="${BASH_REMATCH[2]}"
|
||||
golang_total=$((golang_total + n))
|
||||
echo "METRIC golint_${name}=$n"
|
||||
fi
|
||||
done <<< "$golang_out"
|
||||
echo "METRIC golint_total=$golang_total"
|
||||
|
||||
# ---------- Backend: test-code quality (tests excluded from repo config; safe linters only) ----------
|
||||
test_out=$(golangci-lint run --tests=true --enable=testifylint,usetesting,thelper --enable-only=testifylint,usetesting,thelper 2>&1 || true)
|
||||
golang_test_total=0
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then
|
||||
name="${BASH_REMATCH[1]}"
|
||||
n="${BASH_REMATCH[2]}"
|
||||
golang_test_total=$((golang_test_total + n))
|
||||
echo "METRIC golint_test_${name}=$n"
|
||||
fi
|
||||
done <<< "$test_out"
|
||||
echo "METRIC golint_test_total=$golang_test_total"
|
||||
|
||||
# ---------- Backend: govet extra analyzers (dead code / nil deref — real-bug finders) ----------
|
||||
cat > /tmp/govetx.yml <<'EOF'
|
||||
version: "2"
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- govet
|
||||
settings:
|
||||
govet:
|
||||
enable:
|
||||
- nilness
|
||||
- unusedwrite
|
||||
EOF
|
||||
vetx_out=$(golangci-lint run --config /tmp/govetx.yml --max-issues-per-linter=0 2>&1 || true)
|
||||
rm -f /tmp/govetx.yml
|
||||
golang_vetx_total=0
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then
|
||||
name="${BASH_REMATCH[1]}"
|
||||
n="${BASH_REMATCH[2]}"
|
||||
golang_vetx_total=$((golang_vetx_total + n))
|
||||
echo "METRIC golint_vetx_${name}=$n"
|
||||
fi
|
||||
done <<< "$vetx_out"
|
||||
echo "METRIC golint_vetx_total=$golang_vetx_total"
|
||||
|
||||
# ---------- Frontend: eslint (repo gate) ----------
|
||||
cd frontend
|
||||
eslint_out=$(pnpm exec eslint . --max-warnings 0 2>&1 || true)
|
||||
eslint_problems=0; eslint_errors=0; eslint_warnings=0
|
||||
if [[ "$eslint_out" =~ ([0-9]+)\ problems? ]]; then eslint_problems="${BASH_REMATCH[1]}"; fi
|
||||
if [[ "$eslint_out" =~ \(([0-9]+)\ errors?, ]]; then eslint_errors="${BASH_REMATCH[1]}"; fi
|
||||
if [[ "$eslint_out" =~ ,\ ([0-9]+)\ warnings? ]]; then eslint_warnings="${BASH_REMATCH[1]}"; fi
|
||||
echo "METRIC eslint_problems=$eslint_problems"
|
||||
echo "METRIC eslint_errors=$eslint_errors"
|
||||
echo "METRIC eslint_warnings=$eslint_warnings"
|
||||
|
||||
# ---------- Frontend: tsc (repo gate) ----------
|
||||
tsc_out=$(pnpm exec tsc --noEmit --jsx preserve 2>&1 || true)
|
||||
tsc_errors=$(grep -cE "error TS" <<< "$tsc_out" || true)
|
||||
echo "METRIC tsc_errors=$tsc_errors"
|
||||
|
||||
# ---------- Frontend: vitest (2026-08-16 起全绿,纳入基准防回归) ----------
|
||||
vitest_out=$(pnpm exec vitest run --reporter=dot 2>&1 || true)
|
||||
vitest_failed=0; vitest_total=0
|
||||
if [[ "$vitest_out" =~ ([0-9]+)\ failed ]]; then vitest_failed="${BASH_REMATCH[1]}"; fi
|
||||
if [[ "$vitest_out" =~ Tests[[:space:]]+([0-9]+)\ passed ]]; then vitest_total="${BASH_REMATCH[1]}"; fi
|
||||
if [[ "$vitest_out" =~ Tests[[:space:]]+([0-9]+) ]]; then vitest_total="${BASH_REMATCH[1]}"; fi
|
||||
echo "METRIC vitest_failed=$vitest_failed"
|
||||
echo "METRIC vitest_total=$vitest_total"
|
||||
|
||||
end=$(date +%s)
|
||||
total=$((golang_total + golang_test_total + golang_vetx_total + eslint_problems + tsc_errors + vitest_failed))
|
||||
echo "METRIC total_issues=$total"
|
||||
echo "METRIC measure_s=$((end - start))"
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# Autoresearch: 前后端代码质量符合最佳代码实践
|
||||
|
||||
## Objective
|
||||
|
||||
Improve backend (Go) and frontend (Next.js/TS) code quality so the codebase
|
||||
conforms to best practices. NOT a performance task. Each experiment is a code
|
||||
change that removes real, lint-diagnosed code-quality issues (dead assignments,
|
||||
error-wrapping bugs, non-idiomatic loops, mixed receivers, unsafe error
|
||||
comparisons, unnecessary string fmt, etc.) without changing behavior.
|
||||
|
||||
Genuine quality work only: fix code, never weaken the checks. Do NOT edit
|
||||
`.golangci.yml`, eslint/biome config, or add `nolint`/`eslint-disable`
|
||||
comments to reduce counts. Do NOT reformat code that isn't part of a fix
|
||||
(no formatted-only churn).
|
||||
|
||||
## Metrics
|
||||
|
||||
- **Primary**: `total_issues` (unitless, lower is better) = backend golangci
|
||||
issues (extended linter set below) + frontend eslint problems + tsc errors.
|
||||
- **Secondary**: per-linter counts (`golint_modernize`, `golint_perfsprint`,
|
||||
`golint_errorlint`, `golint_gosec`, `golint_canonicalheader`,
|
||||
`golint_recvcheck`, `golint_wastedassign`, `golint_usestdlibvars`,
|
||||
`golint_intrange`, `golint_forcetypeassert`, `golint_nilnil`,
|
||||
`golint_prealloc`, `golint_errname`, `golint_sloglint`,
|
||||
`golint_copyloopvar`, `golint_mirror`, `golint_nosprintfhostport`),
|
||||
`eslint_problems`, `eslint_errors`, `eslint_warnings`, `tsc_errors`,
|
||||
`measure_s` (benchmark wall time).
|
||||
|
||||
## How to Run
|
||||
|
||||
`./.auto/measure.sh` — outputs `METRIC name=value` lines. Parsed by
|
||||
run_experiment automatically.
|
||||
|
||||
Correctness gate: `./.auto/checks.sh` runs `go vet ./...`, `go build ./...`,
|
||||
and the repo's own `golangci-lint run` (repo config, tests excluded) — all
|
||||
must pass. Note: `go test ./...` is NOT in checks.sh — several tests fail on
|
||||
main today for environmental reasons (no local redis; flaky frpc process
|
||||
tests). Don't "fix" those unless cheap and clearly unrelated to redis/flaky.
|
||||
|
||||
## Benchmark Definition (fixed — never change mid-session)
|
||||
|
||||
Backend: `golangci-lint run --enable=errorlint,errname,nilnil,forcetypeassert,
|
||||
copyloopvar,intrange,mirror,perfsprint,prealloc,usestdlibvars,modernize,
|
||||
sloglint,canonicalheader,nosprintfhostport,recvcheck,wastedassign`
|
||||
(repo `.golangci.yml` linters stay active too; `tests: false` as configured).
|
||||
|
||||
Frontend: `pnpm exec eslint . --max-warnings 0` (repo gate) +
|
||||
`pnpm exec tsc --noEmit --jsx preserve` (repo gate).
|
||||
|
||||
Test-code dimension (added 2026-08-16, run #12+, documented scope extension —
|
||||
raising the bar, not gaming): `golangci-lint run --tests=true
|
||||
--enable=testifylint,usetesting,thelper --enable-only=testifylint,usetesting,thelper`
|
||||
counts test-file quality. DELIBERATELY excludes paralleltest/tparallel
|
||||
(t.Parallel advice is unsafe here: many suites share DB/redis state and tests
|
||||
cannot be run in this env) and gocritic extras (noise). Fix test issues only
|
||||
when compile-safe (go vet compiles tests) and semantically neutral.
|
||||
|
||||
Frontend vitest dimension (added run #19, after suite went green in run #18):
|
||||
`pnpm exec vitest run --reporter=dot` — `vitest_failed` counts into total.
|
||||
The suite is fully runnable locally (jsdom + mocks; no external services).
|
||||
Do not add/remove linters or change settings to make the number go down.
|
||||
|
||||
## Files in Scope
|
||||
|
||||
Backend (Go): `cmd/`, `internal/`, `pkg/`. Anything lint-flagged in the
|
||||
extended set above. Note: module name in go.mod is `github.com/Rain-kl/Wavelet`.
|
||||
|
||||
Frontend (TS/React): `frontend/app/`, `frontend/components/`, `frontend/lib/`,
|
||||
`frontend/contexts/`, `frontend/hooks/`, `frontend/types/`, frontend scripts.
|
||||
|
||||
Infra: `frontend/pnpm-workspace.yaml` — approved @parcel/watcher + @swc/core
|
||||
builds (fixes `make code-check` under pnpm 11; ERR_PNPM_IGNORED_BUILDS
|
||||
otherwise). Already committed in setup.
|
||||
|
||||
## Off Limits
|
||||
|
||||
- `.golangci.yml`, `eslint.config.mjs`, `biome.json` — never touch to reduce counts.
|
||||
- No `//nolint` / `eslint-disable` comments to silence checks.
|
||||
- No reformat-only commits (biome/gofmt churn without a fix).
|
||||
- No behavior changes: refactors must compile (checks.sh gate) and keep tests
|
||||
semantics identical. Re-run checks.sh after every edit.
|
||||
- `frontend/node_modules`, `frontend/bun.lock` (untracked, not ours).
|
||||
- Do not run `go test` suites that need redis/network to declare success.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Backend conventions (AGENTS.md): apps → repository → model layering;
|
||||
`pkg/util/` must not import Gin/GORM/sessions; no `db.DB` in model;
|
||||
response.Abort* for API errors; Chinese docs for content changes
|
||||
(code-quality fixes are not content changes — no doc sync needed unless
|
||||
behavior/UX changes; changelog only for user-visible changes, typically
|
||||
none here).
|
||||
- Frontend: run `pnpm exec biome format --write` only on files you edit
|
||||
(repo `make format` uses biome); keep component placement rules.
|
||||
- `golangci-lint --fix` is allowed and preferred for safe fixes
|
||||
(modernize/intrange/perfsprint/usestdlibvars/canonicalheader/mirror/
|
||||
copyloopvar/sloglint/errname) — review the resulting diff before keeping.
|
||||
For no-fix linters (errorlint wrapping, wastedassign, recvcheck, nilnil,
|
||||
prealloc, forcetypeassert) edit by hand.
|
||||
|
||||
## Workflow per iteration
|
||||
|
||||
1. Read current measure output: which categories remain, where.
|
||||
2. Pick ONE category (or a coherent set of similar fixes), locate files, fix
|
||||
by hand or with golangci-lint --fix scoped to that category.
|
||||
3. `./.auto/measure.sh` → if total dropped → `./.auto/checks.sh` → log keep.
|
||||
If flat/worse → discard or adjust.
|
||||
|
||||
## What's Been Tried
|
||||
|
||||
- Setup commit `ee6974d` (autoresearch/code-quality-2026-08-16): branch,
|
||||
.auto/ session files, frontend/pnpm-workspace.yaml build approvals.
|
||||
- Baseline (before any code fix): total_issues = 108
|
||||
(golangci 107 = modernize 37, perfsprint 18, errorlint 12, canonicalheader 8,
|
||||
recvcheck 7, wastedassign 7, usestdlibvars 3, intrange 3, forcetypeassert 3,
|
||||
nilnil 3, prealloc 3, errname 1, gosec 2; eslint 1 warning
|
||||
[react-hooks/exhaustive-deps in
|
||||
app/(main)/pages/detail/components/pages-source-card.tsx:275]; tsc 0).
|
||||
- Environment notes: golangci-lint 2.12.2 warm cache ~3s; eslint cold ~27s
|
||||
(ignore stderr pnpm noise); go vet+go build ~15-30s after edits.
|
||||
|
||||
### 最终状态(run #23,提交 aa4fadda,本会话收敛点)
|
||||
|
||||
基准 5 维全下限 total=8(全为刻意保留);后端 94 包 + 前端 vitest 116 全绿;
|
||||
`go test -race ./internal/... ./pkg/...` 93 包零警告;`make build-embedded`
|
||||
(发布路径)成功且工作树干净;`make license-check` / `go mod tidy -diff` /
|
||||
`go test -count=3`(时序敏感包)全部通过。checks.sh 门禁:vet + build +
|
||||
golangci + 单测 + vitest + 并发包 -race + license-check。
|
||||
|
||||
### Session result (14 experiments, commits f1f6bb85→65c02ef7)
|
||||
|
||||
108 → **8** (-92.6%) across 3 benchmark dimensions, all remaining 8 are
|
||||
deliberate, documented keepers (see below). Never weakened a check; never
|
||||
added nolint/eslint-disable; benchmark extensions were transparently
|
||||
documented (test-code dimension run #12, exhaustive run #14).
|
||||
|
||||
Fixed (zero behavior change, each reviewed):
|
||||
- gosec 2→0 (saturating multiply pattern gosec accepts without nolint)
|
||||
- modernize 37→5→3 (any, max/min, slices/maps, strings.Cut/SplitSeq,
|
||||
strings.Builder; omitted omitted-lark: nested struct omitzero = wire change)
|
||||
- perfsprint 18→0, canonicalheader 8→0, usestdlibvars 3→0, intrange 3→0,
|
||||
wastedassign 7→0, errname 1→0, forcetypeassert 6→0, prealloc 2→0
|
||||
- errorlint 12→1 (errors.Is/As, %v→%w chains)
|
||||
- recvcheck 7→1 (GORM TableName → pointer receiver; verified gorm source uses
|
||||
reflect.New, tests pass)
|
||||
- eslint 1→0 (exhaustive-deps: add stable `t` to dep array)
|
||||
- test dimension 25→0 (testifylint 20, thelper 3, usetesting 2)
|
||||
- exhaustive 12→0 (explicit enum cases = fail-explicit)
|
||||
|
||||
Deliberate keepers (8) — do NOT "fix" without new evidence:
|
||||
- errorlint 1: pkg/push/telegram.go %v — wrapping the original error would
|
||||
change errors.Is matching semantics; it's intentionally textual context.
|
||||
- modernize 3: nested-struct omitempty (client.go Release/Asset,
|
||||
lark.go Content) — omitzero would CHANGE wire output (plain structs
|
||||
serialize always today).
|
||||
- nilnil 3: not-found/optional-result conventions — postgres_store.go
|
||||
ClickHouseOperationalStats (interface contract, documented in comment),
|
||||
openflare_apply_log.go GetLatestOpenFlareApplyLogByNodeID (tested),
|
||||
github_source_action.go guarded outcome (callers check != nil).
|
||||
- recvcheck 1: MillisecondDuration — encoding/json requires Marshal value
|
||||
receiver + Unmarshal pointer receiver.
|
||||
|
||||
Surveyed and rejected (noise/risk, do not add):
|
||||
- fieldalignment (~100+): JSON key order change + positional literal risk.
|
||||
- sloglint full / gocritic extras: 0 findings.
|
||||
- paralleltest/tparallel: t.Parallel advice unsafe (shared DB/redis state;
|
||||
tests not runnable in this env).
|
||||
- biome format drift (76 files): pure formatting noise; repo's make format
|
||||
covers it.
|
||||
@@ -0,0 +1,129 @@
|
||||
# Deferred proposals — autoresearch run (iterations 27-36)
|
||||
|
||||
Five verified findings deliberately **not** changed by the loop: each needs either a
|
||||
contract/API decision or a multi-package restructure, which this run was scoped to
|
||||
propose rather than perform. Evidence is from reading the cited files in this
|
||||
checkout at commit `ea97b64` plus iterations 27-35.
|
||||
|
||||
---
|
||||
|
||||
## P1 — Cross-driver storage migration cannot move objects (severity: data availability)
|
||||
|
||||
`plugins/domain/upload/task/storage_migration.go` computes `target` from the payload,
|
||||
then calls:
|
||||
|
||||
```go
|
||||
migrated, err := migrateObjects(ctx, storageSvc, storageSvc, total)
|
||||
```
|
||||
|
||||
`sourceBackend` and `targetBackend` are the **same** `contracts.StorageService`. That
|
||||
service resolves its backend per call and only ever to the currently active one
|
||||
(`plugins/infra/storage/plugin.go:89` → `s.backend` or `objectstore.Active(ctx)`), and
|
||||
the target config is persisted **after** the migration loop
|
||||
(`uploadstorage.SaveActiveConfig(ctx, target)`).
|
||||
|
||||
Consequence for a non-empty source: `migrateSingleObject` reads and writes the same
|
||||
backend; `shouldSkipMigration` finds every object already "present in the target" and
|
||||
skips it, yet `migrated` is still incremented, so the task returns
|
||||
`存储迁移完成,共迁移 N 个对象,活动存储已切换为 <driver>` having copied **zero** bytes,
|
||||
and then points the platform at an empty backend. The same-driver and
|
||||
`total == 0` branches are harmless and legitimately need no copying.
|
||||
|
||||
Why the tests miss it: `shared.MockStorageService` is one instance serving both
|
||||
parameters, so a copy-to-self looks correct.
|
||||
|
||||
Proposed fix (needs a contract decision — this is a feature, not a patch):
|
||||
1. Extend `contracts.StorageService` with the ability to operate against an explicitly
|
||||
supplied `StorageConfigDTO` (e.g. `BackendFor(ctx, cfg) (StorageReader, error)`),
|
||||
implemented in `plugins/infra/storage` where the `objectstore` backends live. They
|
||||
are unexported today and `plugins/domain/upload` must not import them (cross-plugin
|
||||
import ban), so the contract is the only correct route.
|
||||
2. In the task, build the target from `target` and pass distinct source/target.
|
||||
3. Only save the active config after a verified copy, and assert `src != dst` at
|
||||
entry.
|
||||
4. Interim safety option if a decision is needed sooner: make the
|
||||
`target.Driver != active.Driver && total > 0` branch return an explicit
|
||||
not-implemented error instead of reporting success. Rejected by this loop because
|
||||
it disables an advertised admin operation, which is a product call, and because
|
||||
the machinery it would strand (`migrateObjects`, `migrateSingleObject`,
|
||||
`shouldSkipMigration`) becomes dead code the project gate then rejects.
|
||||
|
||||
Size: contract + infra impl + task wiring + a two-backend test double. Roughly one
|
||||
focused session, not a loop iteration.
|
||||
|
||||
---
|
||||
|
||||
## P2 — `w_system_configs` has one migration owner and many writers (Cordis single-owner)
|
||||
|
||||
Owner per migrations: `plugins/domain/admin`. Still read/written with raw SQL from
|
||||
`plugins/domain/system/repository.go:31`, `plugins/domain/cap/repository.go:50`,
|
||||
`plugins/domain/auth/repository.go:122`, `plugins/domain/upload/storage/migration.go:96,108`,
|
||||
`plugins/domain/upload/ingest/helpers.go:55`,
|
||||
`plugins/domain/message_gateway/repository/push.go` and `plugins/drivers/driver_http`.
|
||||
|
||||
Iteration 34 fixed one instance of the real damage this causes (a failed read looked
|
||||
identical to "unconfigured", silently dropping notifications); iteration 35 fixed
|
||||
another (a failed read cached a narrowed whitelist for a whole TTL). The remaining
|
||||
sites carry the same trap.
|
||||
|
||||
Proposed fix: one settings accessor contract (`Get(ctx, key) (string, error)` /
|
||||
`GetAll(ctx, keys...)`) owned by the settings subsystem, then delete the raw table
|
||||
access. Keys should be declared where they are used rather than string-matched.
|
||||
Size: medium, touches seven plugins; do it key-group by key-group so each step is
|
||||
independently revertable.
|
||||
|
||||
---
|
||||
|
||||
## P3 — Two tables are modelled twice (schema drift hazard)
|
||||
|
||||
* `w_task_executions`: `plugins/domain/admin/model/entity.go:207` **and**
|
||||
`plugins/drivers/driver_asynq_worker/types.go:49`. The two `TaskExecution` structs
|
||||
and their status enums are byte-for-byte identical today.
|
||||
* `w_schedules`: `plugins/domain/admin/model/entity.go:169` **and**
|
||||
`plugins/drivers/driver_asynq_cron/schedule.go:25`.
|
||||
|
||||
Nothing is broken yet — that is the risk: the migration owner was only recently moved
|
||||
to `admin` (`49f9d10`), and a column added to one struct will silently diverge from
|
||||
the other, so whichever writer holds the stale struct zeroes or omits the new column.
|
||||
|
||||
Proposed fix: pick the single owner per P2's rules and have the other side go through
|
||||
a contract (execution recording already has DTOs in `contracts`), then delete the
|
||||
duplicate model. Consider a gate check rejecting two non-`testhelper` packages
|
||||
declaring the same `w_` table — it will fail until these two are resolved, so land it
|
||||
with the fix (the pattern that worked in iterations 5 and 19).
|
||||
|
||||
---
|
||||
|
||||
## P4 — `user` deletes rows from tables owned by `auth`
|
||||
|
||||
`plugins/domain/user/repository.go:327,330` issues `DELETE` against `w_access_tokens`
|
||||
and `w_external_accounts`, both owned and migrated by `plugins/domain/auth`, inside
|
||||
user deletion. It works, but ownership is inverted: revoke-on-delete is auth's
|
||||
invariant, and encoding it in `user` means any other deletion path silently skips it.
|
||||
|
||||
Proposed fix: emit a typed `user:deleted` event from `user` and let `auth` cascade
|
||||
within its own transaction boundary, or expose an explicit `AuthService.RevokeForUser`.
|
||||
Size: small-to-medium; needs a test that the revocation still happens on delete.
|
||||
|
||||
---
|
||||
|
||||
## P5 — Package `cap` shadows the predeclared identifier (8 of 62 debt)
|
||||
|
||||
Every file in `plugins/domain/cap` declares `package cap`, which shadows the builtin.
|
||||
It is the single largest block of non-cosmetic lint debt this run declined to chase,
|
||||
and it is also a readability cost (`cap.Something` reads as a builtin call).
|
||||
|
||||
Proposed fix: rename to a non-shadowing identifier (e.g. `capacity` / `proofwork`,
|
||||
matching what the plugin actually does) across its own files and importers. Mechanical
|
||||
but wide; needs a decision on the new name first, which is why it is not done here.
|
||||
|
||||
---
|
||||
|
||||
## Explicitly rejected as metric-chasing
|
||||
|
||||
23 `funcorder`, 5 `exhaustive` (both flagged only because
|
||||
`default-signifies-exhaustive` defaults to false), 4 `nonamedreturns` and the 17
|
||||
`forcetypeassert` cluster in `core/events.go` and `core/extpoints/config_resolve.go`
|
||||
— verified guarded by construction (`convertString` etc. return `(any, error)` and
|
||||
always yield the asserted type when `err == nil`). Reordering functions or adding
|
||||
unreachable `if !ok` branches would raise the score and lower the code.
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# Mechanically prove a FIX iteration is load-bearing.
|
||||
#
|
||||
# Usage: .auto/prove_fix.sh <package> <changed source file> [<more files>...]
|
||||
#
|
||||
# Run immediately AFTER committing the fix, with a clean worktree. It reverts
|
||||
# only the non-test source files to their pre-fix state (keeping the new test),
|
||||
# runs the package tests, and requires them to FAIL. Then it restores HEAD.
|
||||
# A fix nobody can break with a revert is not a fix.
|
||||
set -uo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
if [ ! -z "$(git -C "${ROOT}" status --porcelain)" ]; then
|
||||
echo "PROVE ABORT: worktree must be clean (commit the change first)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
PKG="$1"; shift
|
||||
SRC_FILES=("$@")
|
||||
if [ "${#SRC_FILES[@]}" -eq 0 ]; then
|
||||
echo "PROVE ABORT: no source files given"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
cd "${ROOT}/backend" || exit 2
|
||||
|
||||
restore() {
|
||||
git -C "${ROOT}" checkout HEAD -- "${SRC_FILES[@]}" 2>/dev/null
|
||||
}
|
||||
trap restore EXIT
|
||||
|
||||
for f in "${SRC_FILES[@]}"; do
|
||||
if git -C "${ROOT}" cat-file -e "HEAD^:${f}" 2>/dev/null; then
|
||||
git -C "${ROOT}" checkout "HEAD^" -- "${f}" || { echo "PROVE ABORT: cannot revert ${f}"; exit 2; }
|
||||
else
|
||||
# File did not exist before this commit — removing it is the revert.
|
||||
rm -f "${ROOT}/${f}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "--- tests against pre-fix source ---"
|
||||
OUT=$(go test -count=1 "${PKG}" 2>&1)
|
||||
RC=$?
|
||||
echo "${OUT}" | tail -15
|
||||
if [ "${RC}" -eq 0 ]; then
|
||||
echo "PROVE FAILED: tests still pass without the fix — this is not a real bug fix"
|
||||
exit 1
|
||||
fi
|
||||
if echo "${OUT}" | grep -q 'build failed'; then
|
||||
KIND="compile (signature changed; behaviour proven by inspection)"
|
||||
elif echo "${OUT}" | grep -qE '^--- FAIL'; then
|
||||
KIND="assertion"
|
||||
else
|
||||
KIND="failure"
|
||||
fi
|
||||
echo "PROVED: test fails without the fix (${KIND})"
|
||||
exit 0
|
||||
@@ -0,0 +1,37 @@
|
||||
iteration commit metric delta status guard description
|
||||
0 - 102 0.0 baseline pass initial measurement (pinned yardstick: repo gate + real-risk analyzers)
|
||||
1 1c5731b 100 -2.0 keep pass core: Using2/Using3 now wrap dependency causes via errors.Join (proven: test fails on revert)
|
||||
2 37ad586 95 -5.0 keep pass sentinel == comparisons -> errors.Is across admin/upload/cap-pow (5 sites)
|
||||
3 686e3ef 93 -2.0 keep pass filesrv.AbortUploadRecordError dedups error mapping + errors.As (2 sites, drops dead ErrInvalidUploadID)
|
||||
4 7e6b9e7 93 0.0 keep pass PROVEN FIX: singleflight image generation no longer dies with the first caller canceled ctx (test fails on revert)
|
||||
5 381c794 93 0.0 keep pass CORDIS: gate widened to catch bare "go call()" + 4 unprotected cleanup goroutines moved to util.Go (arch violations 4->0)
|
||||
6 ce33997 92 -1.0 keep pass BUGFIX admin logs: negative cursor was accepted (bool ignored by callers) -> error-only contract; proven via revert (compile-level) + contract test
|
||||
7 c66399e 89 -3.0 keep pass push channels share title/content/level extraction (3 dead inits gone, ~20 fewer lines)
|
||||
8 18820b1 89 0.0 keep pass BUGFIX push: synthesized notification content had random field order (map iteration); sorted keys, test observed failing pre-fix
|
||||
9 22ecafd 87 -2.0 keep pass unparam: always-nil error returns dropped, 4 unreachable branches removed
|
||||
10 c4068ef 84 -3.0 keep pass errorlint cleared to 0: %%w at push test + telegram fallback, errors.As in config loader
|
||||
11 3d2038a 80 -4.0 keep pass nilnil: unimplemented auth mocks now return a sentinel instead of (nil,nil)
|
||||
12 101cb2f 79 -1.0 keep pass nilnil: inproc driver GetExecution returns error, matching asynq driver semantics
|
||||
14 6932b54 79 0.0 keep pass DATA-LOSS BUGFIX: cache read error no longer clobbers buffered task log (proven: assertion fails on revert)
|
||||
15 2c41563 79 0.0 keep pass PERF: CORS origin check no longer hits DB per request (5s cached read); proven - loader count 0 vs 1 on revert
|
||||
16 976f9b1 79 0.0 keep pass PERF: contract-level batch user lookup replaces N+1 in access-log enrichment (test proves 1 query vs 3)
|
||||
17 1b1c452 79 0.0 keep pass BUGFIX: orphan cron message_gateway:cleanup_pairing_codes now has a handler; invariant test added (proven by stash-revert)
|
||||
18 8c4955c 79 0.0 keep pass BUGFIX: removed phantom user:daily_audit cron (dispatched to unregistered task); cross-plugin invariant test added
|
||||
19 84eaf3f 79 0.0 keep pass CORDIS+BUGFIX: task handlers were asynq-typed so 4 upload tasks could not run under the in-process worker; made driver-agnostic + gate check 7 (proven: gate names all 3 files pre-fix)
|
||||
20 efa7555 79 0.0 keep pass BUGFIX telegram: LongPoller.Timeout was 10 nanoseconds -> getUpdates timeout=0 -> busy polling; now 10s (proven by reverting the constant)
|
||||
21 1023fa3 79 0.0 keep pass DISK LEAK: telegram inbound media scratch dirs were never removed (no consumer reads them); cleanup on handler exit. No test possible (needs live download)
|
||||
22 ad83841 54 -25.0 keep pass dead lint suppressions removed (24); 2 were load-bearing -> restored+narrowed with reasons after guard veto exposed verified contextcheck FPs
|
||||
23 de938de 54 0.0 keep pass SECURITY/BUGFIX fail-open auth: user+message_gateway consumed contracts.AuthService in Apply but declared only DBService, so reconcile mounted user before auth and loginMW degraded to a pass-through (user change-password/profile/access-tokens unguarded in production, deterministically); declared the dep + added reconcile-level ordering test (PROVED: assertion fails on revert)
|
||||
24 62b48e9 54 0.0 keep pass SECURITY: all three auth-middleware fallbacks were c.Next() (fail-open). Reachable at runtime in admin: OnDispose->ResetServices() nils the global the per-request guard reads, so in-flight requests pass as authenticated. Added ginutil.AuthUnavailable() + table test driving each registered guard (PROVED: abort assertion fails on revert to 577d795)
|
||||
25 f58f5a4 54 0.0 keep pass staticcheck ST1023 x4 from iter 24 (redundant gin.HandlerFunc on typed-RHS decls) - caught by GUARD only, go build/go test both stayed green; lesson: run checks.sh after EVERY commit, not just before ship
|
||||
26 - 64 +10.0 rebaseline pass upstream config-extension + auth/user/task work raised debt 54->64; re-measured at HEAD ea97b64, 47 pkgs pass, arch 0 viol. Run focus agreed: real defects primary, debt secondary (proven-fix gate keeps delta-0 fixes)
|
||||
27 31f3af6 63 -1.0 keep pass dead contextcheck suppression on cmd.newWaveletApp removed; the core.App.Run one was load-bearing (guard veto: project gate contextcheck Run->Start, verified FP on variadic ctx) -> restored narrowed + documented. Lesson 8 trap re-hit: nolintlint "unused" != safe to delete
|
||||
28 5037097 63 0.0 discard fail CORDIS gate: contracts DTO must not carry TableName + removed UserDTO.TableName(). DISCARDED: my grep used -g !*_test.go and missed upload/handler/routers_test.go:669 which does db.Create(&contracts.UserDTO{}) into w_users - that suppression exists precisely to enable the cross-plugin write. Lesson: contracts-purity changes must scan test files too.
|
||||
29 2ff0cb8 63 0.0 keep pass CORDIS contracts purity: gate check 2.2 forbids TableName()/gorm tags in core/contracts + removed UserDTO.TableName(); upload/handler test now seeds via explicit .Table("w_users") (precedent: filesrv test). Proven twice over: gate named auth.go:33 pre-fix, and iter-28 revert broke 1 package without the test fix. Delta-0 keep under the agreed real-defect gate
|
||||
30 9ea0e2b 63 0.0 keep pass SECURITY (assertion-proven): FindUserByFieldRecord interpolated its column arg into WHERE with only a prose comment as guard. Pre-fix the tautology "username = '' OR 1=1 --" EXECUTED and returned a row with err=<nil> (filter bypass). Now an allow-list rejects before GetDB. Also first test in the repository pkg: tests_passed 47->48, funcs 282
|
||||
31 5193bd0 63 0.0 keep pass HARNESS INTEGRITY: measure.sh and checks.sh now key GOLANGCI_LINT_CACHE per checkout. The default cache is machine-wide, so entries written by a sibling worktree replayed here carrying ITS absolute paths (12 of 63 lines pointed at an outside checkout), misattributing findings and risking a stale Guard verdict. Proven count-neutral: cold and warm both 63; foreign paths now 0. Delta 0 by design, kept under the agreed real-defect gate
|
||||
32 f7a86d3 63 0.0 keep pass PERF+DEDUP: three packages hand-rolled mutex+[]string+MatchPathPattern loop, re-normalising and re-splitting immutable patterns per request. New extpoints.PathWhitelist compiles patterns at registration and absorbs all three. Mechanically asserted: 14 allocs/op -> 1 allocs/op; equivalence test pins Match against the legacy loop over a full pattern x path matrix; race-clean. funcs 282->288, coverage 35.02
|
||||
33 99fca9e 62 -1.0 keep pass BUGFIX+DEDUP: task.loadActiveStorageConfig and saveActiveStorageConfig duplicated uploadstorage.LoadStorageConfig/SaveActiveConfig but swallowed all three failures (nil db, read error, json parse) returning zero config + nil error, making the caller-s already-written error branch dead: a storage migration could run from an unknown active driver. Now routed through the canonical accessors; regression test corrupts the stored config and asserts Execute errors (assertion-proven via revert). funcs 289
|
||||
34 b22f863 62 0.0 keep pass BUGFIX+PERF: LoadSMTPConfigRecord fired four single-key queries and discarded every error with underscore assignment, so an unreadable w_system_configs returned four blank strings both callers could only read as "SMTP not configured" -> notification silently dropped. Now one IN query plus a real error channel; callers log at the boundary and keep their own values. Proof is signature-level and exact: the old API had no error return, so the failure was unrepresentable. 4 queries -> 1, funcs 291
|
||||
35 d7c851b 62 0.0 keep pass BUGFIX+PERF: access_cache discarded the whitelist read error with underscore assignment, then unconditionally set valid=true and CheckedAt=now, so one transient DB failure pinned the RESTRICTED default public-access list for the whole TTL and silently narrowed an admin-configured whitelist. Now the error is logged, last-good is served when known, and a cold failure stays invalid so the next request retries. Assertion-proven by dropping and restoring the table mid-test. funcs 292
|
||||
36 - 62 0.0 keep skip PROPOSALS (.auto/proposals.md): five verified items deliberately not auto-fixed per the agreed scope. Headline P1: cross-driver storage migration passes the SAME service as source and target (getBackend only ever resolves the active backend) and saves the target config afterwards, so it reports "migrated N objects" having copied zero bytes and then points the platform at an empty backend. Needs a contracts.StorageService capability decision, not a patch.
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 63.
|
@@ -33,3 +33,4 @@ frontend/*.tsbuildinfo
|
||||
frontend/package-lock.json
|
||||
internal/router/dist/
|
||||
internal/router/root/dist/
|
||||
backend/plugins/drivers/driver_http/dist/
|
||||
|
||||
+8
-1
@@ -1 +1,8 @@
|
||||
* -text
|
||||
* -text
|
||||
backend/openflare/** merge=ours
|
||||
frontend/** merge=ours
|
||||
docs/changelog/** merge=ours
|
||||
docs/superpowers/** merge=ours
|
||||
.github/workflows/build-image.yml merge=ours
|
||||
docker-compose.yml merge=ours
|
||||
.gitconfig merge=ours
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# Repo-local Git settings. Git does not load this file automatically.
|
||||
# From the clone (or worktree) root:
|
||||
# git config include.path ../.gitconfig
|
||||
# Worktree-safe:
|
||||
# git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"
|
||||
# Relative include.path is resolved against .git/config, so ../.gitconfig
|
||||
# is the repo root when .git is a directory (non-worktree clone).
|
||||
|
||||
[merge "ours"]
|
||||
driver = true
|
||||
@@ -18,7 +18,7 @@ permissions:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: openflare-agent
|
||||
DOCKERFILE: docker/Dockerfile.agent
|
||||
DOCKERFILE: manifest/docker/Dockerfile.agent
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -18,7 +18,7 @@ permissions:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: openflare-relay
|
||||
DOCKERFILE: docker/Dockerfile.relay
|
||||
DOCKERFILE: manifest/docker/Dockerfile.relay
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -24,7 +24,7 @@ permissions:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: openflare
|
||||
DOCKERFILE: docker/Dockerfile
|
||||
DOCKERFILE: manifest/docker/Dockerfile
|
||||
|
||||
jobs:
|
||||
# Resolve version / registries once. No checkout: triggers alone determine the tag.
|
||||
|
||||
@@ -18,7 +18,7 @@ permissions:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: openflared
|
||||
DOCKERFILE: docker/Dockerfile.flared
|
||||
DOCKERFILE: manifest/docker/Dockerfile.flared
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Build Image (Wavelet upstream — isolated)
|
||||
|
||||
# Isolated: OpenFlare publishes images via build-image-openflare.yml
|
||||
# (IMAGE_NAME: openflare). This Wavelet workflow is kept under the same
|
||||
# path so `git merge wavelet/main` cannot restore a canary wavelet image.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm:
|
||||
description: "Disabled on OpenFlare. Use build-image-openflare.yml."
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
isolated:
|
||||
name: Isolated
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Refuse Wavelet image publish
|
||||
run: |
|
||||
echo "This Wavelet image workflow is isolated on OpenFlare."
|
||||
echo "Use .github/workflows/build-image-openflare.yml"
|
||||
exit 1
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
|
||||
env:
|
||||
APP_NAME: openflare-server
|
||||
GO_DIR: backend
|
||||
GO_MAIN: ./main.go
|
||||
GO_BUILD_TAGS: embed_frontend
|
||||
GO_LDFLAGS: -s -w
|
||||
@@ -20,12 +21,12 @@ env:
|
||||
FRONTEND_DIR: frontend
|
||||
FRONTEND_BUILD_COMMAND: pnpm build:embed
|
||||
FRONTEND_OUT_DIR: frontend/out
|
||||
EMBED_DIST_DIR: internal/router/root/dist
|
||||
EMBED_DIST_DIR: backend/plugins/drivers/driver_http/dist
|
||||
EXTRA_FILES: |
|
||||
LICENSE
|
||||
README.md
|
||||
README_zh.md
|
||||
config.example.yaml
|
||||
manifest/config/config.default.yaml
|
||||
DEPLOYMENT_zh.md
|
||||
|
||||
permissions:
|
||||
@@ -145,6 +146,7 @@ jobs:
|
||||
rm -rf "$EMBED_DIST_DIR"
|
||||
mkdir -p "$(dirname "$EMBED_DIST_DIR")"
|
||||
cp -R "$FRONTEND_OUT_DIR" "$EMBED_DIST_DIR"
|
||||
test -f "$EMBED_DIST_DIR/index.html"
|
||||
|
||||
- name: Upload embedded frontend
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -192,7 +194,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
go-version-file: ${{ env.GO_DIR }}/go.mod
|
||||
cache: true
|
||||
|
||||
- name: Build binary
|
||||
@@ -212,18 +214,21 @@ jobs:
|
||||
binary_name="${binary_name}.exe"
|
||||
fi
|
||||
|
||||
ldflags="$GO_LDFLAGS -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=$VERSION -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=$BUILD_DATE"
|
||||
# Assert the embedded UI is present so a release cannot ship an API-only binary.
|
||||
test -f "$EMBED_DIST_DIR/index.html"
|
||||
|
||||
ldflags="$GO_LDFLAGS -X Wavelet/pkg/buildinfo.Version=$VERSION -X Wavelet/pkg/buildinfo.BuildTime=$BUILD_DATE"
|
||||
build_args=(
|
||||
-trimpath
|
||||
-ldflags "$ldflags"
|
||||
-o "dist/$binary_name"
|
||||
-o "$GITHUB_WORKSPACE/dist/$binary_name"
|
||||
)
|
||||
|
||||
if [[ -n "$GO_BUILD_TAGS" ]]; then
|
||||
build_args=(-tags "$GO_BUILD_TAGS" "${build_args[@]}")
|
||||
fi
|
||||
|
||||
go build "${build_args[@]}" "$GO_MAIN"
|
||||
(cd "$GO_DIR" && go build "${build_args[@]}" "$GO_MAIN")
|
||||
|
||||
- name: Package artifact
|
||||
id: package
|
||||
@@ -296,7 +301,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
go-version-file: ${{ env.GO_DIR }}/go.mod
|
||||
|
||||
# GeoIP MMDB is not embedded; Docker images COPY mmdb files, bare binaries seed via download on first start.
|
||||
- name: Build Agent
|
||||
@@ -307,9 +312,10 @@ jobs:
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
run: |
|
||||
cd backend
|
||||
go mod download
|
||||
mkdir -p dist
|
||||
go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/agent/config.Version=$VERSION'" -o "dist/$ASSET_NAME" ./cmd/agent/main.go
|
||||
mkdir -p "$GITHUB_WORKSPACE/dist"
|
||||
go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/agent/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/dist/$ASSET_NAME" ./cmd/agent/main.go
|
||||
|
||||
- name: Upload release artifact
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -344,7 +350,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
go-version-file: ${{ env.GO_DIR }}/go.mod
|
||||
|
||||
- name: Build Relay
|
||||
env:
|
||||
@@ -354,9 +360,10 @@ jobs:
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
run: |
|
||||
cd backend
|
||||
go mod download
|
||||
mkdir -p dist
|
||||
go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/relay/config.Version=$VERSION'" -o "dist/$ASSET_NAME" ./cmd/relay/main.go
|
||||
mkdir -p "$GITHUB_WORKSPACE/dist"
|
||||
go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/relay/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/dist/$ASSET_NAME" ./cmd/relay/main.go
|
||||
|
||||
- name: Upload release artifact
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -391,7 +398,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
go-version-file: ${{ env.GO_DIR }}/go.mod
|
||||
|
||||
- name: Build Flared
|
||||
env:
|
||||
@@ -401,9 +408,10 @@ jobs:
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
run: |
|
||||
cd backend
|
||||
go mod download
|
||||
mkdir -p dist
|
||||
go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/flared/config.Version=$VERSION'" -o "dist/$ASSET_NAME" ./cmd/flared/main.go
|
||||
mkdir -p "$GITHUB_WORKSPACE/dist"
|
||||
go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/flared/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/dist/$ASSET_NAME" ./cmd/flared/main.go
|
||||
|
||||
- name: Upload release artifact
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
+18
@@ -11,6 +11,7 @@
|
||||
|
||||
# config
|
||||
config.yaml
|
||||
manifest/config/config.yaml
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -82,3 +83,20 @@ profile.cov
|
||||
|
||||
/.superpowers/
|
||||
/.worktrees/
|
||||
/.pi-subagents/
|
||||
|
||||
# i18n 生成物(由 scripts/merge-i18n-fragments.mjs 从 fragments 生成)
|
||||
frontend/messages/zh-CN.json
|
||||
frontend/messages/en.json
|
||||
|
||||
# 上游 vendoring 目录内禁止出现运行期产物
|
||||
backend/plugins/**/uploads/
|
||||
backend/plugins/**/dist/
|
||||
backend/core/**/dist/
|
||||
backend/pkg/**/uploads/
|
||||
/backend/openflare/plugins/server/upload/filesrv/uploads/
|
||||
/backend/plugins/domain/upload/filesrv/uploads/
|
||||
/backend/plugins/domain/upload/task/uploads/
|
||||
/backend/data/
|
||||
/backend/plugins/drivers/driver_http/dist/
|
||||
/backend/uploads/
|
||||
|
||||
@@ -72,6 +72,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
||||
| `new-async-task` | Asynq 任务、定时任务、TaskHandler、任务元数据 |
|
||||
| `new-setting` | 系统/业务/公开设置、`/admin/system`、`/admin/settings` |
|
||||
| `database-migration` | 表结构、goose 迁移(PG/SQLite/ClickHouse)、seed |
|
||||
| `logstore` | 日志/分析用途表、`backend/internal/repository/logstore`、切换日志主库、PG/SQLite 回落 |
|
||||
| `clickhouse-batchwriter` | CH 批量写入、batchwriter、分析表 flush/背压 |
|
||||
| `file-upload` | 上传/摄取、`upload.Ingest`、文件访问、`w_uploads` |
|
||||
| `cache-framework` | 业务缓存(RAM/Redis/DB)、失效、多节点同步 |
|
||||
@@ -81,18 +82,66 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
||||
|
||||
## 硬性约束
|
||||
|
||||
### 上游/下游改动归属(Cordis)
|
||||
|
||||
- 触碰框架目录 `backend/{core,pkg,plugins}` 前,先判断能力归属:
|
||||
- **通用能力**(与 OpenFlare 业务无关、任何下游都用得上)→ 必须同步在 **Wavelet 上游**完成修改,
|
||||
本仓库通过 `git fetch wavelet && git merge wavelet/main` 取得,不得长期持有本地补丁。
|
||||
- **非通用能力**(OpenFlare 业务特有)→ 在自己的插件内(`backend/openflare/plugins/<name>/`)实现,
|
||||
或新建一个下游插件,禁止塞进上游目录。
|
||||
- 开发下游功能优先**复用上游已有能力**(`core/contracts`、`backend/plugins/*`、`backend/pkg/*`);
|
||||
发现上游已提供而下游仍保留本地副本的,删除本地副本改为复用,或把差量回流上游。
|
||||
- 上游暂缺而确属通用能力时,可先在本仓库实现并登记到 `backend/openflare/upstream-patches.md`
|
||||
(merge 上游后请确认补丁仍在),回流 Wavelet 后删除登记并重新 merge。
|
||||
|
||||
### Cordis 架构核心防线与分层规范
|
||||
- **微内核 (`backend/core/`)**:
|
||||
- 上下文总线(`Context`)、泛型依赖注入(`Container`)、生命周期编排(`Lifecycle`)、扩展点定义(`extpoints/`)与领域事件总线(`EventBus`)。
|
||||
- **严禁**包含任何具体业务逻辑,**严禁** import `gin`、`gorm`、`asynq` 等具体运行时依赖。
|
||||
- **服务契约 (`backend/core/contracts/`)**:
|
||||
- 跨插件通信的统一公开 Go Interface(如 `AuthService`、`UserService`、`CacheService`、`DBService`、`StorageService`)与公共 DTO。
|
||||
- **严禁**包含任何具体业务实现或 SQL 操作。
|
||||
- **自包含插件 (`backend/plugins/`)**:
|
||||
- 所有业务功能与驱动实现均以插件形式存在(`backend/plugins/drivers/`、`backend/plugins/infra/`、`backend/plugins/domain/` 或下游 `backend/openflare/plugins/`)。
|
||||
- 每个插件实现 `core.Plugin`(`Name() string` 与 `Apply(ctx *core.Context) error`)。
|
||||
- **统一插件分层架构与标准模板**:
|
||||
- **开发模板唯一基准**:所有插件统一以 `backend/downstream/plugins/custom_example` 为基准模板构建。
|
||||
- **物理子包隔离规范**:统一采用物理子包结构(`plugin.go`, `consts/`, `controller/`, `service/`, `dao/`, `model/` [含 `entity/`, `do/`], `migrations/` [含 `postgres/`, `sqlite/`])。**严禁在根包平铺 `handlers_*`、`service_*`、`dao_*` 等前缀文件**,子包内文件直接按业务实体命名(如 `hello.go`, `user.go`),严格约束 `controller -> service -> dao -> model` 单向依赖。
|
||||
- **插件通信与依赖隔离**:
|
||||
- **严禁跨包 import internal/私有实现**:插件之间严禁直接 import 对方具体实现包代码。
|
||||
- **单向服务契约调用**:调用方仅面向 `backend/core/contracts` 编程,在 `Apply` 中通过 `core.Provide[contracts.XxxService](ctx, svc)` 注册服务,通过 `core.Inject[contracts.XxxService](ctx)` 或 `ctx.Using(func(svc contracts.XxxService) { ... })` 声明式解析。
|
||||
- **事件总线广播**:状态联动与解耦通信统一通过强类型事件 `ctx.Events().Emit()` 广播,由感兴趣的插件通过 `ctx.Events().On()` 订阅,消除双向依赖与循环引用。
|
||||
- **扩展点自包含注册**:
|
||||
- **HTTP 路由与白名单机制**:
|
||||
- 插件自包含在 `Apply` 中通过 `ctx.Router().Group(...)` 挂载路由与中间件,禁止跨插件散落注册。
|
||||
- **白名单机制**:`driver_http` 与微内核扩展点提供路由白名单支持(`ctx.Router().RegisterWhitelist(patterns...)`),支持精确路径与通配符(如 `/api/v1/oauth/*`)。
|
||||
- **所有权主动声明**:认证域(`auth` 插件)与各业务插件必须在 `Apply` 中主动注册其公开/免鉴权接口(如 `/api/v1/user/login`、`/api/v1/oauth/callback`、`/api/v1/cap/*` 等)。
|
||||
- **鉴权中间件放行防线**:`auth` 提供的登录鉴权中间件(`LoginRequired`)必须先执行白名单匹配并自动放行,彻底杜绝免鉴权接口被全局或组级鉴权中间件误拦截(返回 401 Unauthorized)。
|
||||
- **异步与定时任务**:插件自包含在 `Apply` 中通过 `ctx.Task().Register(...)` 与 `ctx.Schedule().RegisterCron(...)` 声明。
|
||||
- **静态启动配置**:插件自包含在 `Apply` 中通过 `ctx.Config().Bind("<prefix>", &cfg)` 读取**自己声明**的配置,字段以 tag 表达来源:`config`(yaml 路径)、`env`(覆盖变量名)、`default`、`autoEnable`(该变量存在即置真)、`secret`(导出脱敏)。需要在 `Apply` 之前被门禁求值的键,必须在 `DeclareConfig()` 中提前声明并实现 `core.ConfigGatedPlugin`。新增基础设施 key 保持顶层命名(`redis.*`),插件私有配置归 `plugins.<name>.*`。**严禁**再造全局配置单例或在 `backend/pkg/` 读取配置。
|
||||
- **动态设置**:插件自包含在 `Apply` 中通过 `ctx.Settings().Register(core.SettingSchema{...})` 声明可热更新的管理台设置模式(与上面的静态启动配置分属两层)。
|
||||
- **数据迁移**:插件自包含在内部维护 `migrations/*.sql`,通过 `//go:embed` 打包并在 `Apply` 中通过 `ctx.Migrations().Register(pluginID, embedFS)` 注入。
|
||||
- **表单一所有者原则 (Single Owner Principle)**:
|
||||
- 每张数据表有且仅由一个所有者插件声明与维护(表名使用插件前缀如 `w_order_*`)。
|
||||
- 严禁插件 B 跨过所有者插件 A 直接 DDL/DML 旁路读写表 A,必须调用插件 A 暴露的 `contracts` 接口或订阅事件。
|
||||
- **平台服务复用**:
|
||||
- 文件摄取统一使用 `upload.Ingest` / `contracts.StorageService`,禁止绕过存储域直接操作底层 Bucket 或直写文件表。
|
||||
- 业务缓存统一使用 `ctx.Cache()`(`contracts.CacheService`)或标准缓存框架,禁止自研不带失效广播的本地 map。
|
||||
- 数据库操作通过 `ctx.DB()`(`contracts.DBService`)获取受事务与 Trace 保护的连接。
|
||||
|
||||
- 禁止删除 `frontend/node_modules`。
|
||||
- `pkg/util/` 保持纯净:禁止导入 Gin、GORM、sessions 等 HTTP/Web/DB 框架(会话选项在 `internal/apps/oauth/session.go`)。
|
||||
- `backend/pkg/util/` 保持纯净:禁止导入 Gin、GORM、sessions 等 HTTP/Web/DB 框架(会话选项在 `backend/openflare/plugins/server/oauth/session.go`)。
|
||||
- 测试临时目录只用 `t.TempDir()`,禁止硬编码相对路径写源码树。
|
||||
- HTTP 路由仅在 `internal/router/router.go` 注册;`Serve()` 只挂路由与中间件,禁止进程级初始化(如 `SyncEvents`、`InitLogWriter`)。
|
||||
- HTTP 路由只由插件在 `Apply` 中经 `ctx.Router()` 声明;`router.BuildEngine()` 只挂引擎级中间件与前端 SPA 兜底,禁止进程级初始化(如 `SyncEvents`、`InitLogWriter`)。
|
||||
- API 变更后:`make swagger`;开发完成:`make code-check`;提交前:`make format`。
|
||||
- 缓存/文件管理复用平台实现,业务包禁止自建缓存目录或旁路存储后端。
|
||||
- 文件摄取走 `upload.Ingest`(`PolicyCreate` / `PolicyDedupNewRecord` / `PolicyResolveExisting`);删除走 `upload.Remove` / `upload.RemoveOwned`。禁止业务直接 `repository.CreateUpload` / `SoftDeleteUpload` 或 `db.Create(&model.Upload{})`。
|
||||
- **分层**:`apps → repository → model`,`repository → infra/persistence`;禁止 `model → repository`。
|
||||
- `model`:实体、表名、配置 key、查询 DTO、无 IO 规则。禁止 `db.DB` / Redis / CH;禁止 `import repository`。GORM hook 仅可 mutate 自身字段,禁止在 hook 内再查 DB/缓存。
|
||||
- `repository`:唯一持久化入口。apps/logics 禁止为业务 CRUD 直调 `db.DB`(管理端 SQL 控制台、infra 内部等例外保留)。禁止新增 `model.Get/List/Create/...` 类数据访问 API。
|
||||
- 跨模块集成(任务 Handler、推送事件、域监听、完成钩子)禁止 `init()` 注册;经 `internal/platform/bootstrap` 在 `internal/cmd` 入口显式装配。
|
||||
- 核心业务(如 `oauth`、`user`)禁止直接 import push/custom_events;经 `internal/listener` 发域事件,push 在 bootstrap 订阅。
|
||||
- 日志/分析表(节点访问日志、用户访问日志、可观测时序)走 `backend/openflare/plugins/server/kernel/repository/logstore`,禁止 apps 直连 `repository/analytics` 或 `db.ChConn`/`db.ChDB`。判定与接入步骤见 `logstore` skill。
|
||||
- 跨模块集成(任务 Handler、推送事件、域监听、完成钩子)禁止 `init()` 注册;经 `backend/openflare/plugins/server/platform/bootstrap` 在 `backend/cmd` 入口显式装配。
|
||||
- 核心业务(如 `oauth`、`user`)禁止直接 import push/custom_events;经 `backend/openflare/plugins/server/listener` 发域事件,push 在 bootstrap 订阅。
|
||||
- 依赖任务/推送注册的测试须显式 `bootstrap.RegisterTasks()` / `RegisterPushDomainEvents()` 等,不依赖 `init()`。
|
||||
- API 错误必须 `response.Abort*` + `ErrorHandlerMiddleware`;禁止 Handler 直接 `c.JSON(..., response.Err(...))` 或用 HTTP 200 表示失败。
|
||||
|
||||
@@ -130,7 +179,7 @@ Conventional Commits:`<type>(<scope>): <subject>`(例:`feat(auth): support
|
||||
- 命名:动词 + 名词(`ListUsers`);绑定用 `ShouldBindQuery` / `ShouldBindJSON`。
|
||||
- 每个 HTTP API 需完整 Swagger 注释;API 变更后 `make swagger`。
|
||||
- Handler:绑定 → 调 logic → 映射为 `Abort*` 或 `response.OK`。
|
||||
- `logics.go`:接受 `context.Context`,返回结果/error;**禁止**依赖 `*gin.Context`、调用 `Abort*` / `c.JSON`。参考 `internal/apps/user/logics.go`。
|
||||
- `logics.go`:接受 `context.Context`,返回结果/error;**禁止**依赖 `*gin.Context`、调用 `Abort*` / `c.JSON`。参考 `backend/internal/apps/user/logics.go`。
|
||||
|
||||
### API 响应
|
||||
|
||||
@@ -156,7 +205,7 @@ Swagger:`@Success 200` 用具体类型或 `response.Any`;每个可能 Abort
|
||||
|
||||
### 日志
|
||||
|
||||
- 运行时错误(DB/Redis/第三方/IO)在 Handler 或 logic 边界用 `pkg/logger`(带 `ctx`)记录,再返回安全 Abort/业务错误。
|
||||
- 运行时错误(DB/Redis/第三方/IO)在 Handler 或 logic 边界用 `backend/pkg/logger`(带 `ctx`)记录,再返回安全 Abort/业务错误。
|
||||
- 吞错、转通用响应、worker 忽略前必须先记日志。
|
||||
- 禁止 `_ = err` 静默丢弃重要错误;best-effort 可忽略时加简短注释。
|
||||
- 只在处理/抑制边界记一次,避免重复刷日志。
|
||||
@@ -164,7 +213,7 @@ Swagger:`@Success 200` 用具体类型或 `response.Any`;每个可能 Abort
|
||||
### 路由与装配
|
||||
|
||||
- `router.go` 只做高层分发,禁止直接挂业务 Handler。归属与开发步骤见 `new-api` skill。
|
||||
- 跨模块副作用:在 `bootstrap` 增 `Register*`,于对应 `internal/cmd/*.go` 调用(`RegisterAPI` / `RegisterWorker` / `RegisterAll`)。
|
||||
- 跨模块副作用:在 `bootstrap` 增 `Register*`,于对应 `backend/internal/cmd/*.go` 调用(`RegisterAPI` / `RegisterWorker` / `RegisterAll`)。
|
||||
- API/`all` 模式:`bootstrap.Init` 须在 `RegisterPushDomainEvents()` **之后**调用,保证 `SyncEvents` 同步内置推送元数据。
|
||||
|
||||
### 中间件
|
||||
@@ -175,13 +224,13 @@ Swagger:`@Success 200` 用具体类型或 `response.Any`;每个可能 Abort
|
||||
### 配置
|
||||
|
||||
- 运行时只读 `config.Config`,禁止 `os.Getenv()`。
|
||||
- 新增配置同步 `config.example.yaml` 与 `internal/infra/config/model.go`。
|
||||
- 新增配置同步 `config.example.yaml` 与 `backend/internal/infra/config/model.go`。
|
||||
|
||||
### 数据库
|
||||
|
||||
- 持久化只经 `repository`(或 analytics);复杂查询不进 Handler;编排在 logics。
|
||||
- repository 内用 `db.DB(ctx)`(链路追踪)。
|
||||
- 迁移:`internal/infra/persistence/migrator/goose/` SQL;禁止 GORM AutoMigrate。
|
||||
- 迁移:`backend/internal/infra/persistence/migrator/goose/` SQL;禁止 GORM AutoMigrate。
|
||||
- 不建物理外键,关系字段加显式索引。
|
||||
- 列默认值与 Go 零值(`nil`/`0`/`false`/`""`)一致。
|
||||
|
||||
@@ -226,3 +275,10 @@ frontend/lib/services/<name>/
|
||||
|
||||
- 继承 `BaseService`,定义 `basePath`,有类型静态方法;在 `frontend/lib/services/index.ts` 注册。
|
||||
- 回调/`mutationFn`/`queryFn` **禁止**直接传静态方法引用(丢 `this`);用箭头:`(p) => XxxService.create(p)`。
|
||||
|
||||
### 国际化 (i18n)
|
||||
|
||||
- 使用 `next-intl`(无 URL locale 前缀 / provider 模式),兼容 `NEXT_STANDALONE_EXPORT`。
|
||||
- 语言:`zh-CN`、`en`;默认 `zh-CN`。优先级:cookie `NEXT_LOCALE` → 浏览器语言 → 默认。
|
||||
- 文案放在 `frontend/messages/fragments`。参考已有代码,按模块拆文件夹,en.json 和 zh-CN.json 是 ci 生成的(node scripts/merge-i18n-fragments.mjs),禁止手动修改。
|
||||
- 禁止在页面/组件里直接写文案,文案必须支持 i18
|
||||
|
||||
@@ -20,6 +20,16 @@
|
||||
为提高协作效率,我们建议您在提交 PR 前,先通过 Issue 简要说明动机与背景。
|
||||
|
||||
|
||||
## 合并上游
|
||||
|
||||
`.gitattributes` 对 `backend/openflare/`、`frontend/` 等路径使用 `merge=ours`。该驱动不会自动生效,请在仓库根目录执行一次:
|
||||
|
||||
```bash
|
||||
git config include.path ../.gitconfig
|
||||
# worktree 安全写法:
|
||||
git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"
|
||||
```
|
||||
|
||||
## 贡献步骤
|
||||
|
||||
1. **Fork 本仓库** 并创建您的分支(建议使用有意义的分支名)。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
VERSION ?= dev
|
||||
BUILD_DATE ?= $(shell date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
MODULE := $(shell go list -m)
|
||||
MODULE := $(shell cd backend && go list -m)
|
||||
|
||||
swagger:
|
||||
scripts/swagger.sh
|
||||
@@ -19,7 +19,7 @@ format:
|
||||
echo "goimports not found, installing..."; \
|
||||
go install golang.org/x/tools/cmd/goimports@latest; \
|
||||
}
|
||||
goimports -w $$(find . -type f -name '*.go' -not -path './.git/*' -not -path './frontend/*')
|
||||
goimports -w $$(find backend -type f -name '*.go')
|
||||
@echo "==> Formatting frontend source and removing unused imports..."
|
||||
cd frontend && pnpm format
|
||||
|
||||
@@ -29,50 +29,51 @@ build-embedded:
|
||||
NEXT_PUBLIC_APP_VERSION="$(VERSION)" \
|
||||
NEXT_PUBLIC_APP_BUILD_DATE="$(BUILD_DATE)" \
|
||||
pnpm build:embed
|
||||
rm -rf internal/router/root/dist
|
||||
cp -R frontend/out internal/router/root/dist
|
||||
go build \
|
||||
rm -rf backend/plugins/drivers/driver_http/dist
|
||||
cp -R frontend/out backend/plugins/drivers/driver_http/dist
|
||||
test -f backend/plugins/drivers/driver_http/dist/index.html
|
||||
cd backend && go build \
|
||||
-tags embed_frontend \
|
||||
-ldflags "-s -w -X '$(MODULE)/internal/buildinfo.Version=$(VERSION)' -X '$(MODULE)/internal/buildinfo.BuildTime=$(BUILD_DATE)'" \
|
||||
-o bin/openflare-server \
|
||||
-ldflags "-s -w -X '$(MODULE)/pkg/buildinfo.Version=$(VERSION)' -X '$(MODULE)/pkg/buildinfo.BuildTime=$(BUILD_DATE)'" \
|
||||
-o ../bin/openflare-server \
|
||||
main.go
|
||||
|
||||
code-check:
|
||||
@echo "==> Architecture guards..."
|
||||
@command -v rg >/dev/null 2>&1 || { echo 'error: rg (ripgrep) is required for architecture guards' >&2; exit 1; }
|
||||
@if rg -n 'db\.DB\(|db\.Redis' internal/model --glob '*.go' -g '!*_test.go' ; then \
|
||||
scripts/check_cordis_architecture.sh
|
||||
@if rg -n 'db\.DB\(|db\.Redis' backend/openflare/plugins/server/kernel/model --glob '*.go' -g '!*_test.go' ; then \
|
||||
echo 'error: internal/model must not access db.DB or db.Redis (non-test code)' >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
golangci-lint run
|
||||
cd frontend && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
|
||||
cd backend && golangci-lint run
|
||||
cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
|
||||
|
||||
build-backend:
|
||||
@echo "==> Building backend version=$(VERSION) build_date=$(BUILD_DATE)..."
|
||||
go build \
|
||||
-ldflags "-s -w -X '$(MODULE)/internal/buildinfo.Version=$(VERSION)' -X '$(MODULE)/internal/buildinfo.BuildTime=$(BUILD_DATE)'" \
|
||||
-o bin/openflare-server \
|
||||
cd backend && go build \
|
||||
-ldflags "-s -w -X '$(MODULE)/pkg/buildinfo.Version=$(VERSION)' -X '$(MODULE)/pkg/buildinfo.BuildTime=$(BUILD_DATE)'" \
|
||||
-o ../bin/openflare-server \
|
||||
main.go
|
||||
|
||||
build-agent:
|
||||
@echo "==> Building agent version=$(VERSION)..."
|
||||
go build \
|
||||
-ldflags "-s -w -X '$(MODULE)/internal/apps/agent/config.Version=$(VERSION)'" \
|
||||
-o bin/openflare-agent \
|
||||
cd backend && go build \
|
||||
-ldflags "-s -w -X '$(MODULE)/openflare/plugins/agent/config.Version=$(VERSION)'" \
|
||||
-o ../bin/openflare-agent \
|
||||
cmd/agent/main.go
|
||||
|
||||
build-relay:
|
||||
@echo "==> Building relay version=$(VERSION)..."
|
||||
go build \
|
||||
-ldflags "-s -w -X '$(MODULE)/internal/apps/relay/config.Version=$(VERSION)'" \
|
||||
-o bin/openflare-relay \
|
||||
cd backend && go build \
|
||||
-ldflags "-s -w -X '$(MODULE)/openflare/plugins/relay/config.Version=$(VERSION)'" \
|
||||
-o ../bin/openflare-relay \
|
||||
cmd/relay/main.go
|
||||
|
||||
build-flared:
|
||||
@echo "==> Building flared version=$(VERSION)..."
|
||||
go build \
|
||||
-ldflags "-s -w -X '$(MODULE)/internal/apps/flared/config.Version=$(VERSION)'" \
|
||||
-o bin/flared \
|
||||
cd backend && go build \
|
||||
-ldflags "-s -w -X '$(MODULE)/openflare/plugins/flared/config.Version=$(VERSION)'" \
|
||||
-o ../bin/flared \
|
||||
cmd/flared/main.go
|
||||
|
||||
build-all: build-backend build-agent build-relay build-flared
|
||||
@@ -89,7 +90,7 @@ build-test:
|
||||
@PIDS=""; \
|
||||
STATUS=0; \
|
||||
( cd frontend && pnpm build:embed 2>&1 | sed 's/^/[frontend] /' ) & PIDS="$$PIDS $$!"; \
|
||||
( go test ./... && go build -o /dev/null ./... 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
|
||||
( cd backend && go test ./... && go build -o /dev/null ./... 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
|
||||
for PID in $$PIDS; do \
|
||||
wait $$PID || STATUS=1; \
|
||||
done; \
|
||||
@@ -107,7 +108,7 @@ cross-build:
|
||||
(version=$(or $(VERSION),dev))..."
|
||||
@mkdir -p bin
|
||||
docker build \
|
||||
--file docker/Dockerfile.cross \
|
||||
--file manifest/docker/Dockerfile.cross \
|
||||
--target export \
|
||||
--build-arg VERSION=$(or $(VERSION),dev) \
|
||||
--build-arg BUILD_DATE="$(shell date -u +'%Y-%m-%dT%H:%M:%SZ')" \
|
||||
@@ -124,14 +125,14 @@ dev-f:
|
||||
|
||||
dev-b:
|
||||
@echo "==> Starting backend development server..."
|
||||
go run main.go all
|
||||
cd backend && go run main.go all
|
||||
|
||||
dev:
|
||||
@echo "==> Starting frontend and backend development servers in parallel..."
|
||||
@PIDS=""; \
|
||||
STATUS=0; \
|
||||
( cd frontend && pnpm dev 2>&1 | sed 's/^/[frontend] /' ) & PIDS="$$PIDS $$!"; \
|
||||
( go run main.go all 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
|
||||
( cd backend && go run main.go all 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
|
||||
for PID in $$PIDS; do \
|
||||
wait $$PID || STATUS=1; \
|
||||
done; \
|
||||
|
||||
-180
@@ -1,180 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
# OpenFlare
|
||||
|
||||
**[📖 中文](./README.md) | [English](./README.en.md)**
|
||||
|
||||
OpenFlare is an open-source CDN orchestration and edge security platform. It supports reverse proxy, centralized configuration synchronization, in-network tunneling (Tunnels), dynamic WAF protection, and CC defense challenges.
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Rain-kl/OpenFlare?color=brightgreen" alt="license">
|
||||
</a>
|
||||
<a href="https://github.com/Rain-kl/OpenFlare/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/Rain-kl/OpenFlare?color=brightgreen&include_prereleases" alt="release">
|
||||
</a>
|
||||
<a href="https://github.com/Rain-kl/OpenFlare/pkgs/container/openflare">
|
||||
<img src="https://img.shields.io/badge/GHCR-ghcr.io%2Frain--kl%2Fopenflare-brightgreen" alt="ghcr">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> After the first login with the `admin` user, you must change the default password `12345678`.
|
||||
>
|
||||
> The BETA version is a temporary product in the development and testing stage and may have unknown issues. It should not be used in production environments.
|
||||
|
||||
## Documentation
|
||||
|
||||
**https://open-flare.pages.dev**
|
||||
|
||||
Common entry points:
|
||||
|
||||
* [Quick Start](https://open-flare.pages.dev/guide/quick-start)
|
||||
* [Deployment Guide](https://open-flare.pages.dev/deployment/deployment)
|
||||
* [Configuration Reference](https://open-flare.pages.dev/reference/configuration)
|
||||
* [System Design](https://open-flare.pages.dev/design/)
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
* **Reverse Proxy Configuration Management**: Uses website rules as the aggregation boundary, supports multi-domain binding and multi-upstream load balancing, and centrally manages reverse proxy configurations for all OpenResty nodes.
|
||||
* **Secure In-Network Tunneling (Tunnels)**: Open-source version of Cloudflare Tunnels. No public IP or exposed inbound ports are required. Securely reverse-proxy internal web services to the public internet through Relay relay nodes and OpenFlared clients.
|
||||
* **Edge WAF Security Protection**: Provides global and custom rule groups, supports manual/auto/subscription-type IP groups, MaxMind GeoIP national-level geographic access control, IP group member Checksum differential synchronization (no Nginx reload required), and custom blocking responses.
|
||||
* **CC Defense and Human-Computer Challenge (PoW)**: Built-in high-performance client-side cryptography Proof of Work challenge (similar to Turnstile). Secures high-speed interception and blocking of zombie networks and crawlers at the gateway edge.
|
||||
* **Pages Static Hosting**: Supports uploading or synchronizing pre-built artifacts from restricted Remote URLs or public GitHub Release assets. GitHub latest can be checked periodically and optionally auto-published. All sources are unified to generate immutable deployments, pulled by the edge Agent and served locally by OpenResty, supporting rollbacks, SPA Fallback, and API reverse proxy.
|
||||
* **TLS Certificate Automation**: Supports dynamic certificate uploads, automatic multi-domain certificate matching and binding, and automatic issuance and renewal of certificates from Let's Encrypt via the ACME protocol.
|
||||
* **Uptime Kuma Monitoring Synchronization**: Integrated with Uptime Kuma to automatically perform differential synchronization of monitoring site lists, real-time awareness of node availability and service status.
|
||||
* **SSO Single Sign-On**: Supports GitHub OAuth and standard OIDC protocol for seamless integration with enterprise identity providers to achieve unified login.
|
||||
* **Unified Observability**: Aggregates node request metrics, real-time access log details, host and Nginx resource snapshots, health events, and network fluctuation replenishment buffers.
|
||||
|
||||
## Interface Preview
|
||||
|
||||
### Dashboard Overview
|
||||
|
||||

|
||||
|
||||
### Access Logs
|
||||
|
||||

|
||||
|
||||
### WAF Protection
|
||||
|
||||

|
||||
|
||||
## Quick Start
|
||||
|
||||
### Hardware Configuration Recommendations
|
||||
|
||||
| Component | Minimum Hardware Requirements | Recommended Hardware Requirements | Notes |
|
||||
|------------------------|-----------------------------------|-----------------------------------|-------|
|
||||
| **Server Control Plane** | 1 CPU core / 2 GB RAM / 20 GB disk | 2 CPU cores / 4 GB RAM / 50 GB+ disk | Disk usage should be expanded reasonably based on access log retention duration and concurrent traffic |
|
||||
| **Agent Data Plane** | 1 CPU core / 512 MB RAM / 2 GB disk | 2 CPU cores / 2 GB RAM / 10 GB+ disk | Expanded based on OpenResty concurrent proxy connections and WAF interception processing |
|
||||
| **Relay Relay Node** | 1 CPU core / 1 GB RAM / 5 GB disk | 2 CPU cores / 2 GB RAM / 20 GB disk | frps transmission relay throughput is mainly limited by bandwidth and CPU throughput |
|
||||
| **OpenFlared Client** | 1 CPU core / 256 MB RAM / 1 GB disk | 1 CPU core / 512 MB RAM / 5 GB disk | Runs independently on the internal network with extremely low resource consumption; only network throughput needs to be guaranteed |
|
||||
|
||||
### 1. Start the Server
|
||||
|
||||
Use `docker-compose`:
|
||||
|
||||
```bash
|
||||
# Download environment variable template and create .env file
|
||||
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
```yaml
|
||||
services:
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- openflare_uploads:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-openflare}
|
||||
POSTGRES_USER: ${DB_USERNAME:-openflare}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
|
||||
volumes:
|
||||
- openflare_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: valkey/valkey:8.0-alpine
|
||||
restart: unless-stopped
|
||||
command: ["valkey-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- openflare_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
openflare_uploads:
|
||||
openflare_postgres_data:
|
||||
openflare_redis_data:
|
||||
```
|
||||
|
||||
See the [deployment documentation](https://open-flare.pages.dev/deployment/deployment) for details.
|
||||
|
||||
Access address: `http://localhost:3000`
|
||||
|
||||
Default account:
|
||||
|
||||
* Username: `admin`
|
||||
* Password: `12345678`
|
||||
|
||||
### 2. Install Agent
|
||||
|
||||
Before installing the Agent, first install OpenResty on the node or use the built-in OpenResty Agent Docker image.
|
||||
|
||||
You can copy the installation command from the control panel's **Nodes Management -> Details -> Node Information -> Node ID and Deployment**, or use the script below:
|
||||
|
||||
#### Docker Deployment
|
||||
|
||||
Docker deployment can directly run the Agent image:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443/tcp -p 443:443/udp \
|
||||
-v openflare-agent-pages:/data/var/lib/openflare/pages \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
## Open Source License
|
||||
|
||||
This project is licensed under the [Apache License 2.0](./LICENSE).
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Rain-kl%2FOpenFlare&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
# OpenFlare
|
||||
|
||||
**[📖 中文](./README.md) | [English](./README.en.md)**
|
||||
**[English](./README.md) | [简体中文](./README.zh-CN.md)**
|
||||
|
||||
OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、集中式配置同步、内网穿透(Tunnels)、动态 WAF 防护以及防 CC 挑战。
|
||||
OpenFlare is an open-source CDN orchestration and edge security platform. It supports reverse proxy, centralized configuration synchronization, in-network tunneling (Tunnels), dynamic WAF protection, and CC defense challenges.
|
||||
|
||||
</div>
|
||||
|
||||
@@ -21,64 +21,64 @@ OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> 使用 `admin` 用户初次登录系统后,务必修改默认密码 `12345678`。
|
||||
> After the first login with the `admin` user, you must change the default password `12345678`.
|
||||
>
|
||||
> BETA 版本为开发测试阶段的临时产物,可能存在未知问题,请勿在生产环境使用。
|
||||
> The BETA version is a temporary product in the development and testing stage and may have unknown issues. It should not be used in production environments.
|
||||
|
||||
## 文档
|
||||
## Documentation
|
||||
|
||||
**https://open-flare.pages.dev**
|
||||
**https://openflare.fyrn.link**
|
||||
|
||||
常用入口:
|
||||
Common entry points:
|
||||
|
||||
* [快速开始](https://open-flare.pages.dev/guide/quick-start)
|
||||
* [部署说明](https://open-flare.pages.dev/deployment/deployment)
|
||||
* [配置项参考](https://open-flare.pages.dev/reference/configuration)
|
||||
* [系统设计](https://open-flare.pages.dev/design/)
|
||||
* [Quick Start](https://openflare.fyrn.link/guide/quick-start)
|
||||
* [Deployment Guide](https://openflare.fyrn.link/deployment/deployment)
|
||||
* [Configuration Reference](https://openflare.fyrn.link/reference/configuration)
|
||||
* [System Design](https://openflare.fyrn.link/design/)
|
||||
|
||||
## 核心能力
|
||||
## Core Capabilities
|
||||
|
||||
* **反代配置管理**:以网站规则为聚合边界,支持多域名绑定与多上游负载均衡,统一管理所有 OpenResty 节点的反代配置。
|
||||
* **安全内网穿透(Tunnels)**:开源版的 Cloudflare Tunnels。无须公网 IP 或暴露入向端口,通过 Relay 中继节点与 OpenFlared 客户端安全反向穿透内网 Web 服务至公网。
|
||||
* **边缘 WAF 安全防护**:提供全局与自定义规则组,支持手动/自动/订阅型 IP 组、MaxMind GeoIP 国家级地域准入、IP 组成员 Checksum 差分同步(无需 Nginx 重载)以及自定义拦截响应。
|
||||
* **防 CC 与人机挑战(PoW)**:内置高性能客户端密码学 Proof of Work 挑战(类似 Turnstile),在网关边缘秒级拦截并阻断僵尸网络与爬虫。
|
||||
* **Pages 静态托管**:支持上传或从受限 Remote URL、公开 GitHub Release asset 同步预构建产物;GitHub latest 可定时检查并可选自动发布。所有来源统一生成不可变部署,由边缘 Agent 拉取并通过 OpenResty 本地提供服务,支持回滚、SPA Fallback 与 API 反向代理。
|
||||
* **TLS 证书自动化**:支持证书动态上传、多域名证书自动匹配绑定,以及通过 ACME 协议向 Let's Encrypt 自动申请与续期证书。
|
||||
* **Uptime Kuma 监控同步**:与 Uptime Kuma 集成,自动差分同步监控站点列表,实时感知节点存活与服务可用状态。
|
||||
* **SSO 单点登录**:支持 GitHub OAuth 与标准 OIDC 协议,无缝接入企业身份提供商实现统一登录。
|
||||
* **统一观测**:聚合节点请求指标、实时访问日志明细、宿主机与 Nginx 资源快照、健康事件以及网络波动补传缓冲。
|
||||
* **Reverse Proxy Configuration Management**: Uses website rules as the aggregation boundary, supports multi-domain binding and multi-upstream load balancing, and centrally manages reverse proxy configurations for all OpenResty nodes.
|
||||
* **Secure In-Network Tunneling (Tunnels)**: Open-source version of Cloudflare Tunnels. No public IP or exposed inbound ports are required. Securely reverse-proxy internal web services to the public internet through Relay relay nodes and OpenFlared clients.
|
||||
* **Edge WAF Security Protection**: Provides global and custom rule groups, supports manual/auto/subscription-type IP groups, MaxMind GeoIP national-level geographic access control, IP group member Checksum differential synchronization (no Nginx reload required), and custom blocking responses.
|
||||
* **CC Defense and Human-Computer Challenge (PoW)**: Built-in high-performance client-side cryptography Proof of Work challenge (similar to Turnstile). Secures high-speed interception and blocking of zombie networks and crawlers at the gateway edge.
|
||||
* **Pages Static Hosting**: Supports uploading or synchronizing pre-built artifacts from restricted Remote URLs or public GitHub Release assets. GitHub latest can be checked periodically and optionally auto-published. All sources are unified to generate immutable deployments, pulled by the edge Agent and served locally by OpenResty, supporting rollbacks, SPA Fallback, and API reverse proxy.
|
||||
* **TLS Certificate Automation**: Supports dynamic certificate uploads, automatic multi-domain certificate matching and binding, and automatic issuance and renewal of certificates from Let's Encrypt via the ACME protocol.
|
||||
* **Uptime Kuma Monitoring Synchronization**: Integrated with Uptime Kuma to automatically perform differential synchronization of monitoring site lists, real-time awareness of node availability and service status.
|
||||
* **SSO Single Sign-On**: Supports GitHub OAuth and standard OIDC protocol for seamless integration with enterprise identity providers to achieve unified login.
|
||||
* **Unified Observability**: Aggregates node request metrics, real-time access log details, host and Nginx resource snapshots, health events, and network fluctuation replenishment buffers.
|
||||
|
||||
## 界面预览
|
||||
## Interface Preview
|
||||
|
||||
### 仪表盘总览
|
||||
### Dashboard Overview
|
||||
|
||||

|
||||
|
||||
### 访问日志
|
||||
### Access Logs
|
||||
|
||||

|
||||
|
||||
### WAF 防护
|
||||
### WAF Protection
|
||||
|
||||

|
||||
|
||||
## 快速开始
|
||||
## Quick Start
|
||||
|
||||
### 硬件配置推荐
|
||||
### Hardware Configuration Recommendations
|
||||
|
||||
| 组件 | 最低硬件配额 | 推荐硬件配额 | 说明 |
|
||||
| --- |-------------------------------| --- | --- |
|
||||
| **Server 控制面** | 1 核 CPU / 2 GB 内存 / 20 GB 磁盘 | 2 核 CPU / 4 GB 内存 / 50 GB+ 磁盘 | 磁盘用量需根据访问日志留存时长与并发流量合理扩容 |
|
||||
| **Agent 数据面** | 1 核 CPU / 512 MB 内存 / 2 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 10 GB+ 磁盘 | 根据 OpenResty 的并发代理连接量与 WAF 拦截处理扩容 |
|
||||
| **Relay 中继节点**| 1 核 CPU / 1 GB 内存 / 5 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 20 GB 磁盘 | frps 传输中继吞吐量主要受带宽与 CPU 吞吐能力限制 |
|
||||
| **OpenFlared 客户端**| 1 核 CPU / 256 MB 内存 / 1 GB 磁盘 | 1 核 CPU / 512 MB 内存 / 5 GB 磁盘 | 独立运行于内网,自身资源占用极小,保障网络吞吐即可 |
|
||||
| Component | Minimum Hardware Requirements | Recommended Hardware Requirements | Notes |
|
||||
|------------------------|-----------------------------------|-----------------------------------|-------|
|
||||
| **Server Control Plane** | 1 CPU core / 2 GB RAM / 20 GB disk | 2 CPU cores / 4 GB RAM / 50 GB+ disk | Disk usage should be expanded reasonably based on access log retention duration and concurrent traffic |
|
||||
| **Agent Data Plane** | 1 CPU core / 512 MB RAM / 2 GB disk | 2 CPU cores / 2 GB RAM / 10 GB+ disk | Expanded based on OpenResty concurrent proxy connections and WAF interception processing |
|
||||
| **Relay Relay Node** | 1 CPU core / 1 GB RAM / 5 GB disk | 2 CPU cores / 2 GB RAM / 20 GB disk | frps transmission relay throughput is mainly limited by bandwidth and CPU throughput |
|
||||
| **OpenFlared Client** | 1 CPU core / 256 MB RAM / 1 GB disk | 1 CPU core / 512 MB RAM / 5 GB disk | Runs independently on the internal network with extremely low resource consumption; only network throughput needs to be guaranteed |
|
||||
|
||||
### 1. 启动 Server
|
||||
### 1. Start the Server
|
||||
|
||||
使用 docker-compose
|
||||
Use `docker-compose`:
|
||||
|
||||
```bash
|
||||
# 下载环境变量模板并创建 .env 文件
|
||||
# Download environment variable template and create .env file
|
||||
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
|
||||
cp .env.example .env
|
||||
```
|
||||
@@ -135,24 +135,24 @@ volumes:
|
||||
openflare_redis_data:
|
||||
```
|
||||
|
||||
详细部署说明见 [部署文档](https://open-flare.pages.dev/deployment/deployment)。
|
||||
See the [deployment documentation](https://openflare.fyrn.link/deployment/deployment) for details.
|
||||
|
||||
访问地址:`http://localhost:3000`
|
||||
Access address: `http://localhost:3000`
|
||||
|
||||
默认账号:
|
||||
Default account:
|
||||
|
||||
* 用户名:`admin`
|
||||
* 密码:`12345678`
|
||||
* Username: `admin`
|
||||
* Password: `12345678`
|
||||
|
||||
### 2. 安装 Agent
|
||||
### 2. Install Agent
|
||||
|
||||
安装 Agent 前请先在节点上安装 OpenResty,或改用内置 OpenResty 的 Agent Docker 镜像。
|
||||
Before installing the Agent, first install OpenResty on the node or use the built-in OpenResty Agent Docker image.
|
||||
|
||||
你可以在控制面板的节点管理->详情->节点信息->节点标识与部署复制安装命令,或直接使用下面的脚本:
|
||||
You can copy the installation command from the control panel's **Nodes Management -> Details -> Node Information -> Node ID and Deployment**, or use the script below:
|
||||
|
||||
#### Docker 部署
|
||||
#### Docker Deployment
|
||||
|
||||
Docker 部署可直接运行 Agent 镜像:
|
||||
Docker deployment can directly run the Agent image:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
@@ -165,9 +165,21 @@ docker run -d --name openflare-agent --restart unless-stopped \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
## 开源协议
|
||||
## Cordis / Wavelet upstream
|
||||
|
||||
本项目采用 [Apache License 2.0](./LICENSE) 开源。
|
||||
OpenFlare is built on Wavelet Cordis. After cloning, enable `merge=ours` from `.gitattributes` so `git merge wavelet/main` keeps OpenFlare-owned paths:
|
||||
|
||||
```bash
|
||||
git config include.path ../.gitconfig
|
||||
# worktree-safe:
|
||||
git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"
|
||||
```
|
||||
|
||||
`docker compose` uses `docker-compose.yaml`. `docker-compose.wavelet.yml` is the upstream Wavelet stack and is not the product default. Image publishes go through `.github/workflows/build-image-openflare*.yml`; the Wavelet `build-image.yml` is isolated.
|
||||
|
||||
## Open Source License
|
||||
|
||||
This project is licensed under the [Apache License 2.0](./LICENSE).
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
<div align="center">
|
||||
|
||||
# OpenFlare
|
||||
|
||||
**[English](./README.md) | [简体中文](./README.zh-CN.md)**
|
||||
|
||||
OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、集中式配置同步、内网穿透(Tunnels)、动态 WAF 防护以及防 CC 挑战。
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Rain-kl/OpenFlare?color=brightgreen" alt="license">
|
||||
</a>
|
||||
<a href="https://github.com/Rain-kl/OpenFlare/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/Rain-kl/OpenFlare?color=brightgreen&include_prereleases" alt="release">
|
||||
</a>
|
||||
<a href="https://github.com/Rain-kl/OpenFlare/pkgs/container/openflare">
|
||||
<img src="https://img.shields.io/badge/GHCR-ghcr.io%2Frain--kl%2Fopenflare-brightgreen" alt="ghcr">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> 使用 `admin` 用户初次登录系统后,务必修改默认密码 `12345678`。
|
||||
>
|
||||
> BETA 版本为开发测试阶段的临时产物,可能存在未知问题,请勿在生产环境使用。
|
||||
|
||||
## 文档
|
||||
|
||||
**https://openflare.fyrn.link**
|
||||
|
||||
常用入口:
|
||||
|
||||
* [快速开始](https://openflare.fyrn.link/guide/quick-start)
|
||||
* [部署说明](https://openflare.fyrn.link/deployment/deployment)
|
||||
* [配置项参考](https://openflare.fyrn.link/reference/configuration)
|
||||
* [系统设计](https://openflare.fyrn.link/design/)
|
||||
|
||||
## 核心能力
|
||||
|
||||
* **反代配置管理**:以网站规则为聚合边界,支持多域名绑定与多上游负载均衡,统一管理所有 OpenResty 节点的反代配置。
|
||||
* **安全内网穿透(Tunnels)**:开源版的 Cloudflare Tunnels。无须公网 IP 或暴露入向端口,通过 Relay 中继节点与 OpenFlared 客户端安全反向穿透内网 Web 服务至公网。
|
||||
* **边缘 WAF 安全防护**:提供全局与自定义规则组,支持手动/自动/订阅型 IP 组、MaxMind GeoIP 国家级地域准入、IP 组成员 Checksum 差分同步(无需 Nginx 重载)以及自定义拦截响应。
|
||||
* **防 CC 与人机挑战(PoW)**:内置高性能客户端密码学 Proof of Work 挑战(类似 Turnstile),在网关边缘秒级拦截并阻断僵尸网络与爬虫。
|
||||
* **Pages 静态托管**:支持上传或从受限 Remote URL、公开 GitHub Release asset 同步预构建产物;GitHub latest 可定时检查并可选自动发布。所有来源统一生成不可变部署,由边缘 Agent 拉取并通过 OpenResty 本地提供服务,支持回滚、SPA Fallback 与 API 反向代理。
|
||||
* **TLS 证书自动化**:支持证书动态上传、多域名证书自动匹配绑定,以及通过 ACME 协议向 Let's Encrypt 自动申请与续期证书。
|
||||
* **Uptime Kuma 监控同步**:与 Uptime Kuma 集成,自动差分同步监控站点列表,实时感知节点存活与服务可用状态。
|
||||
* **SSO 单点登录**:支持 GitHub OAuth 与标准 OIDC 协议,无缝接入企业身份提供商实现统一登录。
|
||||
* **统一观测**:聚合节点请求指标、实时访问日志明细、宿主机与 Nginx 资源快照、健康事件以及网络波动补传缓冲。
|
||||
|
||||
## 界面预览
|
||||
|
||||
### 仪表盘总览
|
||||
|
||||

|
||||
|
||||
### 访问日志
|
||||
|
||||

|
||||
|
||||
### WAF 防护
|
||||
|
||||

|
||||
|
||||
## 快速开始
|
||||
|
||||
### 硬件配置推荐
|
||||
|
||||
| 组件 | 最低硬件配额 | 推荐硬件配额 | 说明 |
|
||||
| --- |-------------------------------| --- | --- |
|
||||
| **Server 控制面** | 1 核 CPU / 2 GB 内存 / 20 GB 磁盘 | 2 核 CPU / 4 GB 内存 / 50 GB+ 磁盘 | 磁盘用量需根据访问日志留存时长与并发流量合理扩容 |
|
||||
| **Agent 数据面** | 1 核 CPU / 512 MB 内存 / 2 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 10 GB+ 磁盘 | 根据 OpenResty 的并发代理连接量与 WAF 拦截处理扩容 |
|
||||
| **Relay 中继节点**| 1 核 CPU / 1 GB 内存 / 5 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 20 GB 磁盘 | frps 传输中继吞吐量主要受带宽与 CPU 吞吐能力限制 |
|
||||
| **OpenFlared 客户端**| 1 核 CPU / 256 MB 内存 / 1 GB 磁盘 | 1 核 CPU / 512 MB 内存 / 5 GB 磁盘 | 独立运行于内网,自身资源占用极小,保障网络吞吐即可 |
|
||||
|
||||
### 1. 启动 Server
|
||||
|
||||
使用 docker-compose
|
||||
|
||||
```bash
|
||||
# 下载环境变量模板并创建 .env 文件
|
||||
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
```yaml
|
||||
services:
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- openflare_uploads:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-openflare}
|
||||
POSTGRES_USER: ${DB_USERNAME:-openflare}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
|
||||
volumes:
|
||||
- openflare_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: valkey/valkey:8.0-alpine
|
||||
restart: unless-stopped
|
||||
command: ["valkey-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- openflare_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
openflare_uploads:
|
||||
openflare_postgres_data:
|
||||
openflare_redis_data:
|
||||
```
|
||||
|
||||
详细部署说明见 [部署文档](https://openflare.fyrn.link/deployment/deployment)。
|
||||
|
||||
访问地址:`http://localhost:3000`
|
||||
|
||||
默认账号:
|
||||
|
||||
* 用户名:`admin`
|
||||
* 密码:`12345678`
|
||||
|
||||
### 2. 安装 Agent
|
||||
|
||||
安装 Agent 前请先在节点上安装 OpenResty,或改用内置 OpenResty 的 Agent Docker 镜像。
|
||||
|
||||
你可以在控制面板的节点管理->详情->节点信息->节点标识与部署复制安装命令,或直接使用下面的脚本:
|
||||
|
||||
#### Docker 部署
|
||||
|
||||
Docker 部署可直接运行 Agent 镜像:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443/tcp -p 443:443/udp \
|
||||
-v openflare-agent-pages:/data/var/lib/openflare/pages \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
## Cordis / Wavelet 上游
|
||||
|
||||
OpenFlare 构建在 Wavelet Cordis 之上。克隆后请启用 `.gitattributes` 中的 `merge=ours`,这样 `git merge wavelet/main` 会保留 OpenFlare 自有路径:
|
||||
|
||||
```bash
|
||||
git config include.path ../.gitconfig
|
||||
# worktree 安全写法:
|
||||
git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"
|
||||
```
|
||||
|
||||
`docker compose` 使用 `docker-compose.yaml`。`docker-compose.wavelet.yml` 是上游 Wavelet 编排,不是本产品的默认栈。镜像发布走 `.github/workflows/build-image-openflare*.yml`;Wavelet 的 `build-image.yml` 已隔离。
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目采用 [Apache License 2.0](./LICENSE) 开源。
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Rain-kl%2FOpenFlare&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
# wavelet
|
||||
|
||||
🚀 现代化、生产就绪的全栈应用脚手架
|
||||
|
||||
[English](./README.md)
|
||||
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://golang.org/)
|
||||
[](https://nextjs.org/)
|
||||
[](https://reactjs.org/)
|
||||
|
||||
## 📖 项目简介
|
||||
|
||||
**wavelet** 是一个通用型、生产就绪的现代全栈脚手架,后端采用 **Go(Gin + GORM)**,前端采用 **Next.js(App Router + Shadcn UI)**。项目开箱即用,内置构建现代 SaaS、内部工具或开发者平台所需的核心基础设施。
|
||||
|
||||
项目设计理念是 **框架优先、业务中立**:您可以在沿用经过实战检验的底层基础设施的同时,自由接入自己的业务逻辑。
|
||||
|
||||
### ✨ 主要特性
|
||||
|
||||
- 🔐 **多认证方式** — 本地账号密码登录/注册 + 可插拔 OIDC/OAuth2 认证源(支持同时配置多个认证源)
|
||||
- 🗝️ **个人访问令牌** — API Key 管理,支持程序化接口访问;兼容 `Authorization: Bearer` 和 `X-Access-Token` 请求头
|
||||
- 👤 **用户管理** — 管理后台提供用户列表、搜索筛选、启用/禁用账号等功能
|
||||
- ⚙️ **动态系统配置** — KV 系统配置管理,支持实时变更,可通过管理后台界面直接操作
|
||||
- 📋 **异步任务队列** — 基于 [Asynq](https://github.com/hibiken/asynq)(Redis 驱动)的后台任务处理系统,含任务调度面板
|
||||
- 📁 **S3 文件存储** — 通过 S3 兼容 API 统一处理文件上传/下载,支持本地磁盘缓存
|
||||
- 📊 **可观测性** — 结构化日志(Zap)+ 分布式链路追踪(OpenTelemetry)
|
||||
- 🎨 **现代化 UI** — 基于 Tailwind CSS 4 和 Shadcn UI 构建的响应式、支持深色模式的设计系统
|
||||
- 📖 **内置文档中心** — 集成文档门户,包含使用指南、接口文档、隐私政策和服务条款
|
||||
|
||||
## 🏗️ 架构概览
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐
|
||||
│ 前端 │ │ 后端 │ │ 数据库 │
|
||||
│ (Next.js) │◄──►│ (Go) │◄──►│ (PostgreSQL) │
|
||||
│ │ │ │ │ │
|
||||
│ • React 19 │ │ • Gin HTTP 框架 │ │ • PostgreSQL │
|
||||
│ • TypeScript │ │ • GORM ORM │ │ • Redis 缓存 │
|
||||
│ • Tailwind 4 │ │ • 多认证源适配 │ │ │
|
||||
│ • Shadcn UI │ │ • AccessToken 中间件 │ │ │
|
||||
│ │ │ • Asynq 任务队列 │ │ │
|
||||
│ │ │ • OpenTelemetry 链路追踪 │ │ │
|
||||
│ │ │ • Swagger 接口文档 │ │ │
|
||||
└─────────────────┘ └─────────────────────────────┘ └─────────────────┘
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
│ 多进程 CLI 入口 │
|
||||
│ (Cobra + Viper) │
|
||||
│ • api (HTTP) │
|
||||
│ • worker (队列) │
|
||||
│ • scheduler(定时) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
### 后端
|
||||
- **[Go 1.25+](https://go.dev/doc)** — 主语言
|
||||
- **[Gin](https://github.com/gin-gonic/gin)** — HTTP Web 框架
|
||||
- **[GORM](https://github.com/go-gorm/gorm)** — ORM,支持 PostgreSQL 和 ClickHouse
|
||||
- **[Redis](https://github.com/redis/redis)** — 缓存、Session 存储、任务队列后端
|
||||
- **[Asynq](https://github.com/hibiken/asynq)** — 分布式任务队列(Redis 驱动)
|
||||
- **[Cobra + Viper](https://github.com/spf13/cobra)** — CLI 入口 + 配置管理
|
||||
- **[OpenTelemetry](https://opentelemetry.io)** — 分布式链路追踪与可观测性
|
||||
- **[Zap](https://github.com/uber-go/zap)** — 结构化高性能日志
|
||||
- **[Swagger (Swaggo)](https://github.com/swaggo/swag)** — 自动生成 API 文档
|
||||
- **[AWS SDK v2](https://github.com/aws/aws-sdk-go-v2)** — S3 兼容文件存储
|
||||
- **[Snowflake](https://github.com/bwmarrin/snowflake)** — 分布式 ID 生成
|
||||
|
||||
### 前端
|
||||
- **[Next.js 16](https://github.com/vercel/next.js)** — React 框架(App Router)
|
||||
- **[React 19](https://github.com/facebook/react)** — UI 库
|
||||
- **[TypeScript](https://github.com/microsoft/TypeScript)** — 类型安全
|
||||
- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** — 原子化 CSS 框架
|
||||
- **[Shadcn UI](https://github.com/shadcn-ui/ui)** — 可访问、可组合的组件库
|
||||
- **[Lucide Icons](https://github.com/lucide-icons/lucide)** — 图标库
|
||||
|
||||
## 📋 环境要求
|
||||
|
||||
- **Go** >= 1.25
|
||||
- **Node.js** >= 18.0
|
||||
- **PostgreSQL** >= 14
|
||||
- **Redis** >= 6.0
|
||||
- **pnpm** >= 8.0(推荐)
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 克隆仓库
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Rain-kl/Wavelet.git refreshing
|
||||
cd refreshing
|
||||
```
|
||||
|
||||
### 2. 配置环境
|
||||
|
||||
```bash
|
||||
cp manifest/config/config.default.yaml manifest/config/config.yaml
|
||||
```
|
||||
|
||||
编辑 `manifest/config/config.yaml`,配置数据库和 Redis。OIDC 认证源统一在管理后台的系统设置页面运行时配置。
|
||||
|
||||
### 3. 初始化数据库
|
||||
|
||||
```bash
|
||||
# 启动本地依赖服务(PostgreSQL + Redis)
|
||||
docker compose up -d
|
||||
|
||||
# 可选:同时启动 ClickHouse
|
||||
docker compose --profile clickhouse up -d
|
||||
|
||||
# 如果使用外部 PostgreSQL,而不是 Docker 内置服务,则手动创建数据库
|
||||
createdb -h <主机> -p 5432 -U postgres refreshing
|
||||
|
||||
# 数据库表结构在首次启动时自动迁移,无需手动执行
|
||||
```
|
||||
|
||||
### 4. 启动后端
|
||||
|
||||
```bash
|
||||
# 安装 Go 依赖
|
||||
go mod tidy
|
||||
|
||||
# 生成 Swagger 接口文档
|
||||
make swagger
|
||||
|
||||
# 启动 HTTP API 服务器
|
||||
go run main.go api
|
||||
```
|
||||
|
||||
> 后端也支持独立运行 `scheduler` 和 `worker` 进程来处理异步任务:
|
||||
> ```bash
|
||||
> go run main.go scheduler # 定时任务调度器
|
||||
> go run main.go worker # Asynq 任务处理工作进程
|
||||
> ```
|
||||
|
||||
### 5. 启动前端
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 启动开发服务器(Turbopack)
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 6. 访问应用
|
||||
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| 前端界面 | http://localhost:3000 |
|
||||
| Swagger 接口文档 | http://localhost:8000/swagger/index.html |
|
||||
| 健康检查 | http://localhost:8000/api/healthz |
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
主要配置项(完整说明请参考 `manifest/config/config.default.yaml`):
|
||||
|
||||
| 配置项 | 说明 | 示例 |
|
||||
|--------|------|------|
|
||||
| `app.addr` | 后端监听地址 | `:8000` |
|
||||
| `database.host` | PostgreSQL 主机 | `127.0.0.1` |
|
||||
| `database.database` | 数据库名称 | `refreshing` |
|
||||
| `redis.host` | Redis 主机 | `127.0.0.1` |
|
||||
| `storage.endpoint` | S3 兼容存储端点 | `s3.amazonaws.com` |
|
||||
|
||||
## 🔧 开发指南
|
||||
|
||||
### 后端
|
||||
|
||||
```bash
|
||||
# 运行 API 服务器
|
||||
go run main.go api
|
||||
|
||||
# 运行定时任务调度器
|
||||
go run main.go scheduler
|
||||
|
||||
# 运行异步任务工作进程
|
||||
go run main.go worker
|
||||
|
||||
# 修改 Controller 后重新生成 Swagger 文档(必须执行)
|
||||
make swagger
|
||||
|
||||
# 代码格式化与检查
|
||||
make tidy
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# 开发模式(Turbopack)
|
||||
pnpm dev
|
||||
|
||||
# 构建生产版本
|
||||
pnpm build
|
||||
|
||||
# 启动生产服务器
|
||||
pnpm start
|
||||
|
||||
# 代码 Lint 和格式化
|
||||
pnpm lint
|
||||
pnpm format
|
||||
```
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
wavelet/
|
||||
├── main.go # 程序入口(委托给 internal/cmd)
|
||||
├── Makefile # 常用命令(swagger、tidy、license、cross-build)
|
||||
├── manifest/ # 项目清单与编排:docker 镜像构建、deploy (k8s)、config 配置(默认/覆盖)
|
||||
├── docs/ # Swagger 自动生成文档
|
||||
├── frontend/ # Next.js 前端应用
|
||||
│ ├── app/ # App Router 页面
|
||||
│ ├── components/ # React 组件(ui、common、layout)
|
||||
│ ├── lib/services/ # API 服务层
|
||||
│ └── types/ # TypeScript 类型定义
|
||||
└── internal/ # Go 后端(private)
|
||||
├── cmd/ # CLI 命令(api、scheduler、worker)
|
||||
├── apps/ # 业务模块(oauth、user、admin、upload)
|
||||
├── model/ # GORM 实体与业务方法
|
||||
├── router/ # HTTP 路由注册
|
||||
├── task/ # 异步任务定义与工作进程
|
||||
├── db/ # 数据库与 Redis 初始化
|
||||
├── storage/ # S3 文件存储抽象层
|
||||
└── common/ # 公共工具与响应封装
|
||||
```
|
||||
|
||||
## 📚 接口文档
|
||||
|
||||
Swagger 接口文档在后端启动后自动可用:
|
||||
|
||||
```
|
||||
http://localhost:8000/swagger/index.html
|
||||
```
|
||||
|
||||
前端文档中心(路径 `/docs`)内置以下内容:
|
||||
- **使用指南** — 分步入门教程
|
||||
- **接口文档** — 详细接口说明
|
||||
- **隐私政策** — 隐私政策模板(请按需自定义)
|
||||
- **服务条款** — 服务条款模板
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
```bash
|
||||
# 后端测试
|
||||
go test ./...
|
||||
|
||||
# 前端 Lint
|
||||
cd frontend && pnpm lint
|
||||
```
|
||||
|
||||
## 🚀 部署
|
||||
|
||||
### 跨平台二进制编译
|
||||
|
||||
一条命令构建全部 6 个平台的静态二进制文件(Linux / macOS / Windows × amd64 / arm64)。
|
||||
前端已内嵌到每个二进制文件中,无需单独部署。
|
||||
|
||||
**前提条件:** 已安装 Docker 且启用 BuildKit(Docker 23+ 默认开启)。
|
||||
|
||||
```bash
|
||||
# 构建全部 6 个二进制文件 → ./bin/
|
||||
make cross-build
|
||||
|
||||
# 指定版本号
|
||||
make cross-build VERSION=v1.2.3
|
||||
|
||||
# 只构建指定系统(两种架构均会构建)
|
||||
make cross-build GOOS=linux
|
||||
make cross-build GOOS=darwin
|
||||
make cross-build GOOS=windows
|
||||
|
||||
# 只构建指定架构(所有系统均会构建)
|
||||
make cross-build GOARCH=amd64
|
||||
make cross-build GOARCH=arm64
|
||||
|
||||
# 同时指定系统和架构 — 只生成单个文件
|
||||
make cross-build GOOS=linux GOARCH=arm64
|
||||
make cross-build GOOS=darwin GOARCH=amd64 VERSION=v1.2.3
|
||||
```
|
||||
|
||||
输出到 `./bin/` 目录:
|
||||
|
||||
| 文件名 | 平台 |
|
||||
|--------|------|
|
||||
| `wavelet_linux_amd64` | Linux x86-64 |
|
||||
| `wavelet_linux_arm64` | Linux ARM64 |
|
||||
| `wavelet_darwin_amd64` | macOS Intel |
|
||||
| `wavelet_darwin_arm64` | macOS Apple Silicon |
|
||||
| `wavelet_windows_amd64.exe` | Windows x86-64 |
|
||||
| `wavelet_windows_arm64.exe` | Windows ARM64 |
|
||||
|
||||
> 版本号可通过 `wavelet --version` 在运行时查看。
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker build -t refreshing .
|
||||
|
||||
# 运行(通过卷挂载传入配置文件)
|
||||
docker run -d -p 8000:8000 \
|
||||
-v $(pwd)/config.yaml:/app/config.yaml \
|
||||
refreshing api
|
||||
```
|
||||
|
||||
### 生产环境
|
||||
|
||||
1. 构建前端资源:
|
||||
```bash
|
||||
cd frontend && pnpm build
|
||||
```
|
||||
|
||||
2. 编译后端程序:
|
||||
```bash
|
||||
go build -o refreshing main.go
|
||||
```
|
||||
|
||||
3. 配置生产环境的 `config.yaml`。
|
||||
|
||||
4. 启动服务:
|
||||
```bash
|
||||
./refreshing api # HTTP API
|
||||
./refreshing scheduler # 定时调度器(可选)
|
||||
./refreshing worker # 任务工作进程(可选)
|
||||
```
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
我们欢迎社区贡献!请在提交代码前阅读以下文档:
|
||||
|
||||
- [贡献指南](CONTRIBUTING.md)
|
||||
- [行为准则](CODE_OF_CONDUCT.md)
|
||||
- [贡献者许可协议](CLA.md)
|
||||
|
||||
### 贡献流程
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建特性分支 (`git checkout -b feature/your-feature`)
|
||||
3. 提交更改 (`git commit -am 'Add your feature'`)
|
||||
4. 推送到分支 (`git push origin feature/your-feature`)
|
||||
5. 创建 Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目基于 [Apache 2.0 许可证](LICENSE) 开源。
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Command agent runs the OpenFlare edge agent daemon.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"Wavelet/core"
|
||||
agentplugin "Wavelet/openflare/plugins/agent"
|
||||
"Wavelet/openflare/plugins/agent/logging"
|
||||
)
|
||||
|
||||
// shutdownTimeout 为 openresty 收敛与在途配置同步预留的退出窗口。
|
||||
const shutdownTimeout = 60 * time.Second
|
||||
|
||||
func main() {
|
||||
logging.Setup()
|
||||
|
||||
configPath := flag.String("config", "./agent.json", "agent config path")
|
||||
flag.Parse()
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.Profile(agentplugin.DriverTypeAgent)),
|
||||
core.WithShutdownTimeout(shutdownTimeout),
|
||||
)
|
||||
app.Use(agentplugin.New(*configPath))
|
||||
|
||||
if err := app.Prepare(); err != nil {
|
||||
slog.Error("agent startup failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := app.Run(); err != nil {
|
||||
slog.Error("agent process exited with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package cmd 提供 CLI 命令入口
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var allCmd = &cobra.Command{
|
||||
Use: "all",
|
||||
Short: "以融合模式同时启动 API、Worker 和 Scheduler",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
runProfileApp(core.ProfileAll, "all (API + Worker + Scheduler)", true)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var apiCmd = &cobra.Command{
|
||||
Use: "api",
|
||||
Short: "wavelet API",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
runProfileApp(core.ProfileAPI, "api", true)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
ofserver "Wavelet/openflare/plugins/server"
|
||||
"Wavelet/openflare/plugins/server/migrate"
|
||||
"Wavelet/plugins/domain/admin"
|
||||
"Wavelet/plugins/domain/auth"
|
||||
"Wavelet/plugins/domain/msg_gateway"
|
||||
"Wavelet/plugins/domain/risk_control"
|
||||
"Wavelet/plugins/domain/system"
|
||||
"Wavelet/plugins/domain/upload"
|
||||
"Wavelet/plugins/domain/user"
|
||||
"Wavelet/plugins/drivers/driver_asynq_cron"
|
||||
"Wavelet/plugins/drivers/driver_asynq_worker"
|
||||
"Wavelet/plugins/drivers/driver_http"
|
||||
"Wavelet/plugins/drivers/driver_inproc_cron"
|
||||
"Wavelet/plugins/drivers/driver_inproc_worker"
|
||||
"Wavelet/plugins/infra/cache"
|
||||
"Wavelet/plugins/infra/cache_memory"
|
||||
"Wavelet/plugins/infra/config"
|
||||
"Wavelet/plugins/infra/logger"
|
||||
"Wavelet/plugins/infra/storage"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
goosedb "github.com/pressly/goose/v3/database"
|
||||
"gorm.io/gorm"
|
||||
|
||||
infradb "Wavelet/plugins/infra/database"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultShutdownTimeout = 15 * time.Second
|
||||
defaultHTTPAddr = "127.0.0.1:8000"
|
||||
|
||||
// migrationAdvisoryLockKey serializes baseline + plugin Up across Postgres
|
||||
// sessions (ASCII "wave"). SQLite is single-writer and needs no extra lock.
|
||||
migrationAdvisoryLockKey int64 = 0x77617665
|
||||
)
|
||||
|
||||
// runProfileApp prepares and runs the application for a given profile.
|
||||
func runProfileApp(profile core.Profile, mode string, listensForHTTP bool) {
|
||||
app := newOpenFlareApp(profile)
|
||||
if err := app.Prepare(); err != nil {
|
||||
log.Fatalf("[%s] prepare failed: %v\n", mode, err)
|
||||
}
|
||||
state := startupState{
|
||||
mode: mode,
|
||||
listensForHTTP: listensForHTTP,
|
||||
env: app.Context().Config().String("app.env", "production"),
|
||||
}
|
||||
if listensForHTTP {
|
||||
state.addr = app.Context().Config().String("app.addr", defaultHTTPAddr)
|
||||
}
|
||||
printStartupBanner(state)
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatalf("[%s] run failed: %v\n", mode, err)
|
||||
}
|
||||
}
|
||||
|
||||
// newOpenFlareApp creates a core.App wired with Wavelet platform plugins plus the OpenFlare server plugin.
|
||||
func newOpenFlareApp(profile core.Profile, opts ...core.AppOption) *core.App {
|
||||
src, err := config.NewSource()
|
||||
if err != nil {
|
||||
log.Fatalf("[App] load config source failed: %v\n", err)
|
||||
}
|
||||
|
||||
appOpts := []core.AppOption{
|
||||
core.WithProfile(profile),
|
||||
core.WithConfigSource(src),
|
||||
core.WithShutdownTimeout(defaultShutdownTimeout),
|
||||
core.WithMigrationBaseline(migrate.Legacy),
|
||||
}
|
||||
appOpts = append(appOpts, opts...)
|
||||
|
||||
app := core.NewApp(appOpts...)
|
||||
|
||||
// 1. Register standard infrastructure plugins
|
||||
app.Use(
|
||||
infradb.New(),
|
||||
logger.New(),
|
||||
storage.New(),
|
||||
)
|
||||
|
||||
// 2. Register Cache and Async/Cron Drivers (both gated: cache vs cache_memory, asynq vs inproc)
|
||||
app.Use(
|
||||
cache.New(),
|
||||
cache_memory.New(),
|
||||
driver_asynq_worker.New(),
|
||||
driver_inproc_worker.New(),
|
||||
driver_asynq_cron.New(),
|
||||
driver_inproc_cron.New(),
|
||||
)
|
||||
|
||||
// 3. Register all 7 domain business plugins (admin first to ensure schema and base config tables exist)
|
||||
app.Use(
|
||||
admin.New(),
|
||||
user.New(),
|
||||
auth.New(),
|
||||
msg_gateway.New(),
|
||||
risk_control.New(),
|
||||
upload.New(),
|
||||
system.New(),
|
||||
)
|
||||
|
||||
// 4. OpenFlare business routes (after domain plugins, before the HTTP driver)
|
||||
app.Use(
|
||||
ofserver.New(),
|
||||
)
|
||||
|
||||
// 5. Bind Goose migration engine
|
||||
app.SetMigrationEngine(&gooseEngine{})
|
||||
|
||||
// 6. Mount HTTP runtime driver
|
||||
app.Use(
|
||||
driver_http.New(),
|
||||
)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// ─── Schema Version Store ──────────────────────────────────────────────────────
|
||||
|
||||
// sharedStore implements database.Store using a single w_schema_versions table.
|
||||
// All plugins share this table, with plugin_id as the discriminator.
|
||||
//
|
||||
// Schema:
|
||||
//
|
||||
// w_schema_versions (
|
||||
// plugin_id VARCHAR(64) NOT NULL,
|
||||
// version_id BIGINT NOT NULL,
|
||||
// applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
// PRIMARY KEY (plugin_id, version_id)
|
||||
// )
|
||||
type sharedStore struct {
|
||||
pluginID string
|
||||
dialect string // "postgres" or "sqlite3"
|
||||
}
|
||||
|
||||
func (s *sharedStore) Tablename() string { return "w_schema_versions" }
|
||||
|
||||
func (s *sharedStore) CreateVersionTable(ctx context.Context, db goosedb.DBTxConn) error {
|
||||
_, err := db.ExecContext(ctx, schemaVersionsDDL(s.dialect))
|
||||
return err
|
||||
}
|
||||
|
||||
func schemaVersionsDDL(dialect string) string {
|
||||
timeType := "TIMESTAMPTZ"
|
||||
if dialect == "sqlite3" || dialect == "sqlite" {
|
||||
timeType = "DATETIME"
|
||||
}
|
||||
return fmt.Sprintf(`CREATE TABLE IF NOT EXISTS w_schema_versions (
|
||||
plugin_id VARCHAR(64) NOT NULL,
|
||||
version_id BIGINT NOT NULL,
|
||||
applied_at %s NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (plugin_id, version_id)
|
||||
)`, timeType)
|
||||
}
|
||||
|
||||
//nolint:mnd
|
||||
func (s *sharedStore) Insert(ctx context.Context, db goosedb.DBTxConn, req goosedb.InsertRequest) error {
|
||||
p := s.placeholder
|
||||
_, err := db.ExecContext(ctx,
|
||||
fmt.Sprintf("INSERT INTO w_schema_versions (plugin_id, version_id) VALUES (%s, %s) ON CONFLICT (plugin_id, version_id) DO NOTHING", p(1), p(2)),
|
||||
s.pluginID, req.Version)
|
||||
return err
|
||||
}
|
||||
|
||||
//nolint:mnd
|
||||
func (s *sharedStore) Delete(ctx context.Context, db goosedb.DBTxConn, version int64) error {
|
||||
p := s.placeholder
|
||||
_, err := db.ExecContext(ctx,
|
||||
fmt.Sprintf("DELETE FROM w_schema_versions WHERE plugin_id = %s AND version_id = %s", p(1), p(2)),
|
||||
s.pluginID, version)
|
||||
return err
|
||||
}
|
||||
|
||||
//nolint:mnd
|
||||
func (s *sharedStore) GetMigration(ctx context.Context, db goosedb.DBTxConn, version int64) (*goosedb.GetMigrationResult, error) {
|
||||
p := s.placeholder
|
||||
var t time.Time
|
||||
err := db.QueryRowContext(ctx,
|
||||
fmt.Sprintf("SELECT applied_at FROM w_schema_versions WHERE plugin_id = %s AND version_id = %s", p(1), p(2)),
|
||||
s.pluginID, version).Scan(&t)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, goosedb.ErrVersionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &goosedb.GetMigrationResult{Timestamp: t, IsApplied: true}, nil
|
||||
}
|
||||
|
||||
func (s *sharedStore) GetLatestVersion(ctx context.Context, db goosedb.DBTxConn) (int64, error) {
|
||||
p := s.placeholder
|
||||
var version int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
fmt.Sprintf("SELECT COALESCE(MAX(version_id), 0) FROM w_schema_versions WHERE plugin_id = %s", p(1)),
|
||||
s.pluginID).Scan(&version)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (s *sharedStore) ListMigrations(ctx context.Context, db goosedb.DBTxConn) ([]*goosedb.ListMigrationsResult, error) {
|
||||
p := s.placeholder
|
||||
rows, err := db.QueryContext(ctx,
|
||||
fmt.Sprintf("SELECT version_id, TRUE FROM w_schema_versions WHERE plugin_id = %s ORDER BY version_id DESC", p(1)),
|
||||
s.pluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var results []*goosedb.ListMigrationsResult
|
||||
for rows.Next() {
|
||||
var r goosedb.ListMigrationsResult
|
||||
if err := rows.Scan(&r.Version, &r.IsApplied); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, &r)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sharedStore) placeholder(n int) string {
|
||||
if s.dialect == "postgres" {
|
||||
return fmt.Sprintf("$%d", n)
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// ─── Migration Engine ──────────────────────────────────────────────────────────
|
||||
|
||||
// gooseEngine implements core.MigrationEngine by iterating all plugin-registered
|
||||
// migration entries and applying each plugin's migrations against the shared DB.
|
||||
//
|
||||
// Each plugin owns its own `migrations/*.sql` directory, embedded via go:embed
|
||||
// and registered via ctx.Migrations().Register(pluginID, embedFS).
|
||||
//
|
||||
// Version tracking: all plugins share a single w_schema_versions table with
|
||||
// plugin_id as the discriminator column. Querying this table shows the current
|
||||
// migration version of every plugin at a glance.
|
||||
type gooseEngine struct{}
|
||||
|
||||
func (e *gooseEngine) Migrate(ctx *core.Context, entries []core.MigrationEntry) error {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve DBService from the IoC container.
|
||||
var dbSvc contracts.DBService
|
||||
if err := core.Using[contracts.DBService](ctx, func(svc contracts.DBService) {
|
||||
dbSvc = svc
|
||||
}); err != nil {
|
||||
return fmt.Errorf("migration: resolve DBService: %w", err)
|
||||
}
|
||||
|
||||
gormDB := dbSvc.GORM()
|
||||
if gormDB == nil {
|
||||
return fmt.Errorf("migration: DBService.GORM() returned nil")
|
||||
}
|
||||
|
||||
sqlDB, err := gormDB.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("migration: get underlying DB from GORM: %w", err)
|
||||
}
|
||||
|
||||
dialect := gooseDialectFromGORM(gormDB, ctx)
|
||||
dialectStr := string(dialect)
|
||||
goCtx := context.Background()
|
||||
if ctx != nil {
|
||||
goCtx = ctx.GoContext()
|
||||
}
|
||||
if goCtx == nil {
|
||||
goCtx = context.Background()
|
||||
}
|
||||
|
||||
bootstrap := &sharedStore{dialect: dialectStr}
|
||||
if err := bootstrap.CreateVersionTable(goCtx, sqlDB); err != nil {
|
||||
return fmt.Errorf("migration: create version table: %w", err)
|
||||
}
|
||||
|
||||
if dialect == goose.DialectPostgres {
|
||||
conn, lockErr := sqlDB.Conn(goCtx)
|
||||
if lockErr != nil {
|
||||
return fmt.Errorf("migration: pin connection for advisory lock: %w", lockErr)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
if _, lockErr = conn.ExecContext(goCtx, "SELECT pg_advisory_lock($1)", migrationAdvisoryLockKey); lockErr != nil {
|
||||
return fmt.Errorf("migration: advisory lock: %w", lockErr)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.ExecContext(context.Background(), "SELECT pg_advisory_unlock($1)", migrationAdvisoryLockKey)
|
||||
}()
|
||||
}
|
||||
|
||||
if fn := ctx.MigrationBaseline(); fn != nil {
|
||||
if err := fn(ctx); err != nil {
|
||||
return fmt.Errorf("migration baseline: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
store := &sharedStore{
|
||||
pluginID: entry.PluginID,
|
||||
dialect: dialectStr,
|
||||
}
|
||||
|
||||
migrationFS := findMigrationFS(entry.FS, dialect)
|
||||
provider, err := goose.NewProvider(goose.DialectCustom, sqlDB, migrationFS, goose.WithStore(store))
|
||||
if err != nil {
|
||||
return fmt.Errorf("migration %s: create provider: %w", entry.PluginID, err)
|
||||
}
|
||||
|
||||
results, err := provider.Up(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("migration %s: apply %w", entry.PluginID, err)
|
||||
}
|
||||
|
||||
version, vErr := provider.GetDBVersion(context.Background())
|
||||
if vErr != nil {
|
||||
version = 0
|
||||
}
|
||||
|
||||
if len(results) > 0 {
|
||||
log.Printf("[migrate] %s: applied %d migration(s) (v%d)", entry.PluginID, len(results), version)
|
||||
} else {
|
||||
log.Printf("[migrate] %s: v%d", entry.PluginID, version)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// gooseDialectFromGORM prefers the live driver; config is only a fallback when
|
||||
// GORM has no dialector yet (tests that inject a stub DBService).
|
||||
func gooseDialectFromGORM(gormDB *gorm.DB, ctx *core.Context) goose.Dialect {
|
||||
if gormDB != nil && gormDB.Dialector != nil && gormDB.Dialector.Name() == "postgres" {
|
||||
return goose.DialectPostgres
|
||||
}
|
||||
if gormDB != nil && gormDB.Dialector != nil && gormDB.Dialector.Name() == "sqlite" {
|
||||
return goose.DialectSQLite3
|
||||
}
|
||||
return gooseDialect(ctx)
|
||||
}
|
||||
|
||||
// gooseDialect returns the goose dialect based on the configured database engine.
|
||||
func gooseDialect(ctx *core.Context) goose.Dialect {
|
||||
if ctx != nil && ctx.Config() != nil && ctx.Config().Bool("database.enabled", false) {
|
||||
return goose.DialectPostgres
|
||||
}
|
||||
return goose.DialectSQLite3
|
||||
}
|
||||
|
||||
func findMigrationFS(rootFS fs.FS, dialect goose.Dialect) fs.FS {
|
||||
dialectDir := "postgres"
|
||||
if dialect == goose.DialectSQLite3 {
|
||||
dialectDir = "sqlite"
|
||||
}
|
||||
|
||||
// 1. Direct search for dialect folder (e.g., "sqlite", "migrations/sqlite", "logstore/migrations/sqlite")
|
||||
for _, subDir := range []string{
|
||||
dialectDir,
|
||||
"migrations/" + dialectDir,
|
||||
"logstore/migrations/" + dialectDir,
|
||||
} {
|
||||
if sub, err := fs.Sub(rootFS, subDir); err == nil {
|
||||
if matches, err := fs.Glob(sub, "*.sql"); err == nil && len(matches) > 0 {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Recursive walk to find a directory named dialectDir with *.sql files
|
||||
var foundDir string
|
||||
_ = fs.WalkDir(rootFS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err == nil && d.IsDir() && filepath.Base(path) == dialectDir {
|
||||
if sub, subErr := fs.Sub(rootFS, path); subErr == nil {
|
||||
if matches, globErr := fs.Glob(sub, "*.sql"); globErr == nil && len(matches) > 0 {
|
||||
foundDir = path
|
||||
return fs.SkipAll
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if foundDir != "" && foundDir != "." {
|
||||
if sub, err := fs.Sub(rootFS, foundDir); err == nil {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to generic migrations / root if dialect specific is not present
|
||||
for _, subDir := range []string{"migrations", "logstore/migrations"} {
|
||||
if sub, err := fs.Sub(rootFS, subDir); err == nil {
|
||||
if matches, err := fs.Glob(sub, "*.sql"); err == nil && len(matches) > 0 {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rootFS
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"Wavelet/core"
|
||||
)
|
||||
|
||||
func testSource(t *testing.T) core.ConfigSource {
|
||||
t.Helper()
|
||||
return core.NewMapSource(map[string]any{
|
||||
"app": map[string]any{
|
||||
"addr": "127.0.0.1:0",
|
||||
"env": "testing",
|
||||
},
|
||||
"redis": map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
"database": map[string]any{
|
||||
"enabled": false,
|
||||
"sqlite_path": filepath.Join(t.TempDir(), "openflare-cmd.db"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewOpenFlareAppRegistersServerAndWaveletUser(t *testing.T) {
|
||||
app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(testSource(t)))
|
||||
if err := app.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, p := range app.Plugins() {
|
||||
names[p.Name()] = true
|
||||
}
|
||||
for _, n := range []string{"user", "auth", "admin", "server"} {
|
||||
if !names[n] {
|
||||
t.Errorf("missing plugin %s", n)
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := map[string]bool{}
|
||||
for _, rd := range app.Context().Router().Routes() {
|
||||
got[rd.Method+" "+rd.Path] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"GET /api/healthz",
|
||||
"GET /api/v1/user/self",
|
||||
"GET /api/v1/d/nodes",
|
||||
"POST /api/v1/cap/challenge",
|
||||
} {
|
||||
if !got[want] {
|
||||
t.Errorf("missing route %s", want)
|
||||
}
|
||||
}
|
||||
for _, drop := range []string{
|
||||
"GET /api/health",
|
||||
"GET /healthz",
|
||||
"POST /api/cap/challenge",
|
||||
} {
|
||||
if got[drop] {
|
||||
t.Errorf("removed route still registered: %s", drop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreshInstallSeedsOpenFlareDefaults(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "fresh.db")
|
||||
app := cordisPrepare(t, cordisSQLiteSource(t, dbPath))
|
||||
t.Cleanup(func() { _ = app.Context().Dispose() })
|
||||
|
||||
db := openInspectDB(t, dbPath, "")
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
var tables int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name LIKE 'of_%'`).Scan(&tables); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tables == 0 {
|
||||
t.Fatal("fresh install created no of_* tables")
|
||||
}
|
||||
|
||||
rows, err := db.Query(`SELECT task_type FROM w_schedules WHERE task_type LIKE 'of_%' ORDER BY 1`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var got []string
|
||||
for rows.Next() {
|
||||
var taskType string
|
||||
if err := rows.Scan(&taskType); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, taskType)
|
||||
}
|
||||
want := []string{
|
||||
"of_pages_source_scan",
|
||||
"of_ssl_renew",
|
||||
"of_uptime_kuma_sync",
|
||||
"of_waf_ip_group_sync",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("of_* schedules = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("of_* schedules = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
var cleanup int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM w_schedules WHERE task_type = 'of_database_auto_cleanup'`).Scan(&cleanup); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cleanup != 0 {
|
||||
t.Fatal("must not seed of_database_auto_cleanup")
|
||||
}
|
||||
|
||||
var geoip string
|
||||
if err := db.QueryRow(`SELECT value FROM w_system_configs WHERE key = 'geoip_provider'`).Scan(&geoip); err != nil {
|
||||
t.Fatalf("geoip_provider: %v", err)
|
||||
}
|
||||
if geoip != "ipinfo" {
|
||||
t.Fatalf("geoip_provider = %q, want ipinfo", geoip)
|
||||
}
|
||||
}
|
||||
@@ -4,28 +4,33 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/buildinfo"
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/buildinfo"
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/config"
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/persistence/migrator"
|
||||
)
|
||||
|
||||
type startupState struct {
|
||||
mode string
|
||||
relationalDB migrator.Report
|
||||
clickHouseDB migrator.Report
|
||||
listensForHTTP bool
|
||||
env string
|
||||
addr string
|
||||
}
|
||||
|
||||
func printStartupBanner(state startupState) {
|
||||
log.Print(formatStartupBanner(state))
|
||||
fmt.Println(formatStartupBanner(state))
|
||||
}
|
||||
|
||||
func formatStartupBanner(state startupState) string {
|
||||
env := state.env
|
||||
if env == "" {
|
||||
env = "production"
|
||||
}
|
||||
addr := state.addr
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:3000"
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
"",
|
||||
" ____ ________ ",
|
||||
@@ -36,14 +41,12 @@ func formatStartupBanner(state startupState) string {
|
||||
" /_/ ",
|
||||
fmt.Sprintf(" OpenFlare %s", buildinfo.Version),
|
||||
"",
|
||||
fmt.Sprintf(" Environment: %s", config.Config.App.Env),
|
||||
fmt.Sprintf(" Environment: %s", env),
|
||||
fmt.Sprintf(" Runtime: %s/%s (%s)", runtime.GOOS, runtime.GOARCH, runtime.Version()),
|
||||
fmt.Sprintf(" Build time: %s", buildTime()),
|
||||
fmt.Sprintf(" Database: %s", formatMigration(state.relationalDB)),
|
||||
fmt.Sprintf(" Analytics: %s", formatMigration(state.clickHouseDB)),
|
||||
}
|
||||
if state.listensForHTTP {
|
||||
lines = append(lines, fmt.Sprintf(" Listening: http://%s", config.Config.App.Addr))
|
||||
lines = append(lines, fmt.Sprintf(" Listening: http://%s", addr))
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" Mode: %s", state.mode), "")
|
||||
return strings.Join(lines, "\n")
|
||||
@@ -55,14 +58,3 @@ func buildTime() string {
|
||||
}
|
||||
return buildinfo.BuildTime
|
||||
}
|
||||
|
||||
func formatMigration(report migrator.Report) string {
|
||||
if !report.Enabled {
|
||||
return "disabled"
|
||||
}
|
||||
state := "up to date"
|
||||
if report.Applied {
|
||||
state = "upgraded"
|
||||
}
|
||||
return fmt.Sprintf("%s (version %d, %s)", report.Backend, report.Version, state)
|
||||
}
|
||||
@@ -4,49 +4,33 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/buildinfo"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/buildinfo"
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/config"
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/persistence/migrator"
|
||||
)
|
||||
|
||||
func TestFormatStartupBanner(t *testing.T) {
|
||||
previousVersion := buildinfo.Version
|
||||
previousBuildTime := buildinfo.BuildTime
|
||||
previousEnv := config.Config.App.Env
|
||||
previousAddr := config.Config.App.Addr
|
||||
t.Cleanup(func() {
|
||||
buildinfo.Version = previousVersion
|
||||
buildinfo.BuildTime = previousBuildTime
|
||||
config.Config.App.Env = previousEnv
|
||||
config.Config.App.Addr = previousAddr
|
||||
})
|
||||
|
||||
buildinfo.Version = "v3.2.1"
|
||||
buildinfo.BuildTime = "2026-07-13T08:00:00Z"
|
||||
config.Config.App.Env = "production"
|
||||
config.Config.App.Addr = ":3000"
|
||||
|
||||
banner := formatStartupBanner(startupState{
|
||||
mode: "API",
|
||||
relationalDB: migrator.Report{
|
||||
Backend: "PostgreSQL",
|
||||
Enabled: true,
|
||||
Version: 202607150003,
|
||||
Applied: true,
|
||||
},
|
||||
clickHouseDB: migrator.Report{Backend: "ClickHouse"},
|
||||
mode: "API",
|
||||
listensForHTTP: true,
|
||||
env: "production",
|
||||
addr: ":3000",
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
"OpenFlare v3.2.1",
|
||||
"Environment: production",
|
||||
"Build time: 2026-07-13T08:00:00Z",
|
||||
"Database: PostgreSQL (version 202607150003, upgraded)",
|
||||
"Analytics: disabled",
|
||||
"Listening: http://:3000",
|
||||
"Mode: API",
|
||||
} {
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Command flared runs the OpenFlare tunnel client daemon.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"Wavelet/core"
|
||||
flaredplugin "Wavelet/openflare/plugins/flared"
|
||||
edgelogging "Wavelet/openflare/share/edge/logging"
|
||||
)
|
||||
|
||||
// shutdownTimeout 为 frpc 子进程收敛预留的退出窗口。
|
||||
const shutdownTimeout = 60 * time.Second
|
||||
|
||||
func main() {
|
||||
edgelogging.Setup(edgelogging.Options{})
|
||||
|
||||
configPath := flag.String("config", "./flared.json", "flared config path")
|
||||
flag.Parse()
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.Profile(flaredplugin.DriverTypeFlared)),
|
||||
core.WithShutdownTimeout(shutdownTimeout),
|
||||
)
|
||||
app.Use(flaredplugin.New(*configPath))
|
||||
|
||||
if err := app.Prepare(); err != nil {
|
||||
slog.Error("flared startup failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := app.Run(); err != nil {
|
||||
slog.Error("flared process exited with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type migrateTestDB struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (s migrateTestDB) GORM() *gorm.DB { return s.db }
|
||||
|
||||
func (s migrateTestDB) DB(ctx context.Context) *gorm.DB { return s.db.WithContext(ctx) }
|
||||
|
||||
func (s migrateTestDB) Named(string) *gorm.DB { return s.db }
|
||||
|
||||
type migrateTestPlugin struct {
|
||||
name string
|
||||
db *gorm.DB
|
||||
fs fstest.MapFS
|
||||
}
|
||||
|
||||
func (p *migrateTestPlugin) Name() string {
|
||||
if p.name != "" {
|
||||
return p.name
|
||||
}
|
||||
return "t"
|
||||
}
|
||||
|
||||
func (p *migrateTestPlugin) Apply(ctx *core.Context) error {
|
||||
core.Provide[contracts.DBService](ctx, migrateTestDB{db: p.db})
|
||||
ctx.Migrations().Register(p.Name(), p.fs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteTableExists(t *testing.T, db *gorm.DB, name string) bool {
|
||||
t.Helper()
|
||||
var n int
|
||||
err := db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", name).Scan(&n).Error
|
||||
require.NoError(t, err)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func testMigrationFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"migrations/sqlite/00001_init.sql": &fstest.MapFile{Data: []byte(`-- +goose Up
|
||||
CREATE TABLE t_up (id INTEGER PRIMARY KEY);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE t_up;
|
||||
`)},
|
||||
}
|
||||
}
|
||||
|
||||
func openMigrateTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "migrate.db")
|
||||
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
return gdb
|
||||
}
|
||||
|
||||
func TestGooseEngineMigrateOrderCreateTableBaselineUp(t *testing.T) {
|
||||
gdb := openMigrateTestDB(t)
|
||||
var order []string
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithMigrationEngine(&gooseEngine{}),
|
||||
core.WithMigrationBaseline(func(*core.Context) error {
|
||||
require.True(t, sqliteTableExists(t, gdb, "w_schema_versions"), "version table must exist before baseline")
|
||||
require.False(t, sqliteTableExists(t, gdb, "t_up"), "plugin Up must not run before baseline")
|
||||
order = append(order, "create-table", "baseline")
|
||||
return nil
|
||||
}),
|
||||
core.WithPlugins(&migrateTestPlugin{db: gdb, fs: testMigrationFS()}),
|
||||
)
|
||||
|
||||
require.NoError(t, app.Prepare())
|
||||
require.NoError(t, app.ApplyPlugins())
|
||||
require.NoError(t, app.RunMigrations())
|
||||
|
||||
require.True(t, sqliteTableExists(t, gdb, "t_up"), "plugin Up must run after baseline")
|
||||
order = append(order, "up")
|
||||
assert.Equal(t, []string{"create-table", "baseline", "up"}, order)
|
||||
}
|
||||
|
||||
func TestGooseEngineBaselineErrorSkipsUp(t *testing.T) {
|
||||
gdb := openMigrateTestDB(t)
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithMigrationEngine(&gooseEngine{}),
|
||||
core.WithMigrationBaseline(func(*core.Context) error {
|
||||
require.True(t, sqliteTableExists(t, gdb, "w_schema_versions"), "version table must exist before baseline")
|
||||
return assert.AnError
|
||||
}),
|
||||
core.WithPlugins(&migrateTestPlugin{db: gdb, fs: testMigrationFS()}),
|
||||
)
|
||||
|
||||
require.NoError(t, app.Prepare())
|
||||
require.NoError(t, app.ApplyPlugins())
|
||||
err := app.RunMigrations()
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "migration baseline")
|
||||
assert.False(t, sqliteTableExists(t, gdb, "t_up"), "plugin Up must not run when baseline fails")
|
||||
}
|
||||
|
||||
func TestGooseEngineNilBaselineStillMigrates(t *testing.T) {
|
||||
gdb := openMigrateTestDB(t)
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithMigrationEngine(&gooseEngine{}),
|
||||
core.WithPlugins(&migrateTestPlugin{db: gdb, fs: testMigrationFS()}),
|
||||
)
|
||||
|
||||
require.NoError(t, app.Prepare())
|
||||
require.NoError(t, app.ApplyPlugins())
|
||||
require.NoError(t, app.RunMigrations())
|
||||
assert.True(t, sqliteTableExists(t, gdb, "w_schema_versions"))
|
||||
assert.True(t, sqliteTableExists(t, gdb, "t_up"))
|
||||
}
|
||||
|
||||
func TestGooseEngineUpgradesFrom00001To00002(t *testing.T) {
|
||||
gdb := openMigrateTestDB(t)
|
||||
runTestMigrations(t, gdb, testMigrationFS(), "")
|
||||
require.True(t, sqliteTableExists(t, gdb, "t_up"))
|
||||
require.False(t, sqliteTableExists(t, gdb, "t_v2"))
|
||||
require.Equal(t, int64(1), pluginSchemaVersion(t, gdb, "t"))
|
||||
|
||||
runTestMigrations(t, gdb, testMigrationFSWithV2("sqlite"), "")
|
||||
require.True(t, sqliteTableExists(t, gdb, "t_up"), "00001 table must survive 00002")
|
||||
require.True(t, sqliteTableExists(t, gdb, "t_v2"), "00002 must create t_v2")
|
||||
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "t"))
|
||||
require.Equal(t, 1, tableRowCount(t, gdb, "t_v2"))
|
||||
|
||||
runTestMigrations(t, gdb, testMigrationFSWithV2("sqlite"), "")
|
||||
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "t"), "second 00002 run must be a no-op")
|
||||
require.Equal(t, 1, tableRowCount(t, gdb, "t_v2"), "00002 INSERT must not run twice")
|
||||
}
|
||||
|
||||
func TestGooseEngineStampedV1AppliesOnly00002(t *testing.T) {
|
||||
gdb := openMigrateTestDB(t)
|
||||
require.NoError(t, gdb.Exec(`CREATE TABLE w_schema_versions (
|
||||
plugin_id VARCHAR(64) NOT NULL,
|
||||
version_id BIGINT NOT NULL,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (plugin_id, version_id)
|
||||
)`).Error)
|
||||
require.NoError(t, gdb.Exec(`INSERT INTO w_schema_versions (plugin_id, version_id) VALUES ('t', 1)`).Error)
|
||||
|
||||
runTestMigrations(t, gdb, testMigrationFSWithV2("sqlite"), "")
|
||||
require.False(t, sqliteTableExists(t, gdb, "t_up"), "stamped v1 must not re-run 00001")
|
||||
require.True(t, sqliteTableExists(t, gdb, "t_v2"), "stamped v1 must still apply 00002")
|
||||
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "t"))
|
||||
}
|
||||
|
||||
func TestOpenFlareServerUpgradesFrom00001To00002(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "of.db")
|
||||
app := cordisPrepare(t, cordisSQLiteSource(t, dbPath))
|
||||
require.NoError(t, app.Context().Dispose())
|
||||
|
||||
inspect := openInspectDB(t, dbPath, "")
|
||||
if !pluginHasVersion(t, inspect, false, serverPluginStamp, 1) {
|
||||
t.Fatal("fresh install did not apply server 00001")
|
||||
}
|
||||
_ = inspect.Close()
|
||||
|
||||
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
require.NoError(t, err)
|
||||
runTestMigrations(t, gdb, serverFollowupFS("sqlite"), "server")
|
||||
|
||||
require.False(t, sqliteTableExists(t, gdb, "should_not_exist_from_00001_rerun"))
|
||||
require.True(t, sqliteTableExists(t, gdb, "of_upgrade_probe"))
|
||||
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "server"))
|
||||
require.True(t, sqliteTableExists(t, gdb, "of_zones"), "existing of_* tables must survive 00002")
|
||||
require.Equal(t, 1, tableRowCount(t, gdb, "of_upgrade_probe"))
|
||||
}
|
||||
|
||||
func TestGooseEngineUpgradesFrom00001To00002Postgres(t *testing.T) {
|
||||
gdb := openMigratePostgresDB(t)
|
||||
opts := postgresMigrateOpt()
|
||||
runTestMigrations(t, gdb, testPostgresMigrationFS(), "", opts)
|
||||
require.True(t, pgTableExists(t, gdb, "t_up"))
|
||||
require.False(t, pgTableExists(t, gdb, "t_v2"))
|
||||
require.Equal(t, int64(1), pluginSchemaVersion(t, gdb, "t"))
|
||||
|
||||
runTestMigrations(t, gdb, testMigrationFSWithV2("postgres"), "", opts)
|
||||
require.True(t, pgTableExists(t, gdb, "t_up"))
|
||||
require.True(t, pgTableExists(t, gdb, "t_v2"))
|
||||
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "t"))
|
||||
require.Equal(t, 1, tableRowCount(t, gdb, "t_v2"))
|
||||
|
||||
runTestMigrations(t, gdb, testMigrationFSWithV2("postgres"), "", opts)
|
||||
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "t"))
|
||||
require.Equal(t, 1, tableRowCount(t, gdb, "t_v2"))
|
||||
}
|
||||
|
||||
func TestOpenFlareServerUpgradesFrom00001To00002Postgres(t *testing.T) {
|
||||
host, port, user, pass, dbName, sslMode, cleanup := createMigratePostgresDB(t)
|
||||
t.Cleanup(cleanup)
|
||||
dsn := postgresDSN(host, port, user, pass, dbName, sslMode)
|
||||
app := cordisPrepare(t, cordisPostgresSource(t, host, port, user, pass, dbName, sslMode))
|
||||
require.NoError(t, app.Context().Dispose())
|
||||
|
||||
inspect := openInspectDB(t, "", dsn)
|
||||
if !pluginHasVersion(t, inspect, true, serverPluginStamp, 1) {
|
||||
t.Fatal("fresh install did not apply server 00001")
|
||||
}
|
||||
_ = inspect.Close()
|
||||
|
||||
gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
require.NoError(t, err)
|
||||
runTestMigrations(t, gdb, serverFollowupFS("postgres"), "server", postgresMigrateOpt())
|
||||
|
||||
require.False(t, pgTableExists(t, gdb, "should_not_exist_from_00001_rerun"))
|
||||
require.True(t, pgTableExists(t, gdb, "of_upgrade_probe"))
|
||||
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "server"))
|
||||
require.True(t, pgTableExists(t, gdb, "of_zones"))
|
||||
require.Equal(t, 1, tableRowCount(t, gdb, "of_upgrade_probe"))
|
||||
}
|
||||
|
||||
func runTestMigrations(t *testing.T, gdb *gorm.DB, fs fstest.MapFS, pluginName string, opts ...core.AppOption) {
|
||||
t.Helper()
|
||||
plugin := &migrateTestPlugin{name: pluginName, db: gdb, fs: fs}
|
||||
appOpts := []core.AppOption{
|
||||
core.WithMigrationEngine(&gooseEngine{}),
|
||||
core.WithPlugins(plugin),
|
||||
}
|
||||
appOpts = append(appOpts, opts...)
|
||||
app := core.NewApp(appOpts...)
|
||||
require.NoError(t, app.Prepare())
|
||||
require.NoError(t, app.ApplyPlugins())
|
||||
require.NoError(t, app.RunMigrations())
|
||||
}
|
||||
|
||||
func postgresMigrateOpt() core.AppOption {
|
||||
return core.WithConfigSource(core.NewMapSource(map[string]any{
|
||||
"database": map[string]any{"enabled": true},
|
||||
}))
|
||||
}
|
||||
|
||||
func testPostgresMigrationFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"migrations/postgres/00001_init.sql": &fstest.MapFile{Data: []byte(`-- +goose Up
|
||||
CREATE TABLE t_up (id BIGINT PRIMARY KEY);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE t_up;
|
||||
`)},
|
||||
}
|
||||
}
|
||||
|
||||
func testMigrationFSWithV2(dialect string) fstest.MapFS {
|
||||
v1 := `-- +goose Up
|
||||
CREATE TABLE t_up (id BIGINT PRIMARY KEY);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE t_up;
|
||||
`
|
||||
v2 := `-- +goose Up
|
||||
CREATE TABLE t_v2 (id BIGINT PRIMARY KEY, note TEXT NOT NULL DEFAULT '');
|
||||
INSERT INTO t_v2 (id, note) VALUES (1, 'from-00002');
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE t_v2;
|
||||
`
|
||||
if dialect == "sqlite" {
|
||||
v1 = `-- +goose Up
|
||||
CREATE TABLE t_up (id INTEGER PRIMARY KEY);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE t_up;
|
||||
`
|
||||
v2 = `-- +goose Up
|
||||
CREATE TABLE t_v2 (id INTEGER PRIMARY KEY, note TEXT NOT NULL DEFAULT '');
|
||||
INSERT INTO t_v2 (id, note) VALUES (1, 'from-00002');
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE t_v2;
|
||||
`
|
||||
}
|
||||
return fstest.MapFS{
|
||||
"migrations/" + dialect + "/00001_init.sql": &fstest.MapFile{Data: []byte(v1)},
|
||||
"migrations/" + dialect + "/00002_add_t_v2.sql": &fstest.MapFile{Data: []byte(v2)},
|
||||
}
|
||||
}
|
||||
|
||||
func serverFollowupFS(dialect string) fstest.MapFS {
|
||||
v1 := `-- +goose Up
|
||||
CREATE TABLE should_not_exist_from_00001_rerun (id INTEGER);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE should_not_exist_from_00001_rerun;
|
||||
`
|
||||
v2 := `-- +goose Up
|
||||
CREATE TABLE of_upgrade_probe (id INTEGER PRIMARY KEY, note TEXT NOT NULL DEFAULT '');
|
||||
INSERT INTO of_upgrade_probe (id, note) VALUES (1, 'from-00002');
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE of_upgrade_probe;
|
||||
`
|
||||
if dialect == "postgres" {
|
||||
v1 = `-- +goose Up
|
||||
CREATE TABLE should_not_exist_from_00001_rerun (id BIGINT);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE should_not_exist_from_00001_rerun;
|
||||
`
|
||||
v2 = `-- +goose Up
|
||||
CREATE TABLE of_upgrade_probe (id BIGINT PRIMARY KEY, note TEXT NOT NULL DEFAULT '');
|
||||
INSERT INTO of_upgrade_probe (id, note) VALUES (1, 'from-00002');
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE of_upgrade_probe;
|
||||
`
|
||||
}
|
||||
return fstest.MapFS{
|
||||
"migrations/" + dialect + "/00001_initial.sql": &fstest.MapFile{Data: []byte(v1)},
|
||||
"migrations/" + dialect + "/00002_upgrade_probe.sql": &fstest.MapFile{Data: []byte(v2)},
|
||||
}
|
||||
}
|
||||
|
||||
func pluginSchemaVersion(t *testing.T, db *gorm.DB, pluginID string) int64 {
|
||||
t.Helper()
|
||||
var v int64
|
||||
err := db.Raw(`SELECT COALESCE(MAX(version_id), 0) FROM w_schema_versions WHERE plugin_id = ?`, pluginID).Scan(&v).Error
|
||||
require.NoError(t, err)
|
||||
return v
|
||||
}
|
||||
|
||||
func tableRowCount(t *testing.T, db *gorm.DB, name string) int {
|
||||
t.Helper()
|
||||
if !safePGIdent(name) {
|
||||
t.Fatalf("unsafe table name %q", name)
|
||||
}
|
||||
var n int
|
||||
err := db.Raw("SELECT COUNT(*) FROM " + name).Scan(&n).Error
|
||||
require.NoError(t, err)
|
||||
return n
|
||||
}
|
||||
|
||||
func pgTableExists(t *testing.T, db *gorm.DB, name string) bool {
|
||||
t.Helper()
|
||||
var n int
|
||||
err := db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ?`, name).Scan(&n).Error
|
||||
require.NoError(t, err)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func openMigratePostgresDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
host, port, user, pass, dbName, sslMode, cleanup := createMigratePostgresDB(t)
|
||||
t.Cleanup(cleanup)
|
||||
dsn := postgresDSN(host, port, user, pass, dbName, sslMode)
|
||||
gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
require.NoError(t, err)
|
||||
return gdb
|
||||
}
|
||||
|
||||
func createMigratePostgresDB(t *testing.T) (host string, port int, user, pass, dbName, sslMode string, cleanup func()) {
|
||||
t.Helper()
|
||||
dsn := strings.TrimSpace(os.Getenv("TEST_PG_DSN"))
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_PG_DSN is not set")
|
||||
}
|
||||
host, port, user, pass, adminDB, sslMode := parsePostgresDSN(t, dsn)
|
||||
adminDSN := postgresDSN(host, port, user, pass, adminDB, sslMode)
|
||||
admin := openInspectDB(t, "", adminDSN)
|
||||
dbName = fmt.Sprintf("of_mig_%d", time.Now().UnixNano())
|
||||
if !safePGIdent(dbName) {
|
||||
t.Fatalf("generated database name %q is not a safe identifier", dbName)
|
||||
}
|
||||
if _, err := admin.Exec("CREATE DATABASE " + dbName); err != nil {
|
||||
t.Fatalf("CREATE DATABASE %s: %v", dbName, err)
|
||||
}
|
||||
cleanup = func() {
|
||||
_, _ = admin.Exec(`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, dbName)
|
||||
_, _ = admin.Exec("DROP DATABASE IF EXISTS " + dbName)
|
||||
_ = admin.Close()
|
||||
}
|
||||
return host, port, user, pass, dbName, sslMode, cleanup
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"Wavelet/core"
|
||||
)
|
||||
|
||||
// baselineRoutesFile 是改造前遗留注册路径导出的 (方法 路径) 全集。
|
||||
const baselineRoutesFile = "docs/superpowers/specs/baseline/routes-engine.txt"
|
||||
|
||||
func TestPluginRoutesContainGoldenBaseline(t *testing.T) {
|
||||
app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(testSource(t)))
|
||||
if err := app.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := routeSet(app.Context())
|
||||
want := loadBaseline(t)
|
||||
for _, drop := range []string{
|
||||
"GET /api/health",
|
||||
"GET /healthz",
|
||||
"POST /api/cap/challenge",
|
||||
"POST /api/cap/redeem",
|
||||
} {
|
||||
delete(want, drop)
|
||||
}
|
||||
for k := range want {
|
||||
if !got[k] {
|
||||
t.Errorf("missing golden route %s", k)
|
||||
}
|
||||
}
|
||||
for _, must := range []string{
|
||||
"GET /api/healthz",
|
||||
"POST /api/v1/cap/challenge",
|
||||
"POST /api/v1/cap/redeem",
|
||||
} {
|
||||
if !got[must] {
|
||||
t.Errorf("missing required route %s", must)
|
||||
}
|
||||
}
|
||||
for _, drop := range []string{
|
||||
"GET /api/health",
|
||||
"GET /healthz",
|
||||
"POST /api/cap/challenge",
|
||||
"POST /api/cap/redeem",
|
||||
} {
|
||||
if got[drop] {
|
||||
t.Errorf("removed route still registered: %s", drop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func routeSet(ctx *core.Context) map[string]bool {
|
||||
set := make(map[string]bool)
|
||||
for _, rd := range ctx.Router().Routes() {
|
||||
set[rd.Method+" "+rd.Path] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func loadBaseline(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
path := locateFile(t, baselineRoutesFile)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read baseline %s: %v", path, err)
|
||||
}
|
||||
set := make(map[string]bool)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
set[line] = true
|
||||
}
|
||||
}
|
||||
if len(set) == 0 {
|
||||
t.Fatalf("baseline %s is empty", path)
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func locateFile(t *testing.T, rel string) string {
|
||||
t.Helper()
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
dir := filepath.Dir(thisFile)
|
||||
for range 8 {
|
||||
candidate := filepath.Join(dir, rel)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
dir = filepath.Join(dir, "..")
|
||||
}
|
||||
t.Fatalf("%s not found above %s", rel, filepath.Dir(thisFile))
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Command relay runs the OpenFlare relay node daemon.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"Wavelet/core"
|
||||
relayplugin "Wavelet/openflare/plugins/relay"
|
||||
edgelogging "Wavelet/openflare/share/edge/logging"
|
||||
)
|
||||
|
||||
// shutdownTimeout 为 frps 子进程收敛预留的退出窗口。
|
||||
const shutdownTimeout = 60 * time.Second
|
||||
|
||||
func main() {
|
||||
edgelogging.Setup(edgelogging.Options{})
|
||||
|
||||
configPath := flag.String("config", "./relay.json", "relay config path")
|
||||
flag.Parse()
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.Profile(relayplugin.DriverTypeRelay)),
|
||||
core.WithShutdownTimeout(shutdownTimeout),
|
||||
)
|
||||
app.Use(relayplugin.New(*configPath))
|
||||
|
||||
if err := app.Prepare(); err != nil {
|
||||
slog.Error("relay startup failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := app.Run(); err != nil {
|
||||
slog.Error("relay process exited with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/plugins/domain/auth"
|
||||
"Wavelet/plugins/infra/database"
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
@@ -13,12 +15,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/persistence/migrator"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/platform/bootstrap"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
userdomain "Wavelet/plugins/domain/user"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -47,12 +45,14 @@ func generateRandomPassword(length int) (string, error) {
|
||||
var resetPasswdCmd = &cobra.Command{
|
||||
Use: "reset-passwd",
|
||||
Short: "重置指定账号密码",
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
migrator.Migrate()
|
||||
},
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
ctx := context.Background()
|
||||
runBootstrap(bootstrap.Options{})
|
||||
|
||||
// Ensure database is initialized
|
||||
dbConn := database.DB(ctx)
|
||||
if dbConn != nil {
|
||||
userdomain.SetDBService(database.NewService(dbConn))
|
||||
}
|
||||
|
||||
var username string
|
||||
if usernameFlag != "" {
|
||||
@@ -70,7 +70,7 @@ var resetPasswdCmd = &cobra.Command{
|
||||
}
|
||||
}
|
||||
|
||||
user, err := repository.GetUserByUsername(ctx, username)
|
||||
user, err := userdomain.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
log.Fatalf("错误: 用户 '%s' 不存在\n", username)
|
||||
@@ -92,26 +92,26 @@ var resetPasswdCmd = &cobra.Command{
|
||||
log.Fatalf("加密密码失败: %v\n", err)
|
||||
}
|
||||
|
||||
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err = database.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&user).Update("password", user.Password).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate existing tokens
|
||||
var tokens []model.AccessToken
|
||||
var tokens []userdomain.AccessToken
|
||||
if err := tx.Where("user_id = ?", user.ID).Find(&tokens).Error; err == nil {
|
||||
for _, token := range tokens {
|
||||
oauth.InvalidateCachedToken(ctx, token.TokenHash)
|
||||
auth.InvalidateCachedToken(ctx, token.TokenHash)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Where("user_id = ?", user.ID).Delete(&model.AccessToken{}).Error
|
||||
return tx.Where("user_id = ?", user.ID).Delete(&userdomain.AccessToken{}).Error
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("重置密码失败: %v\n", err)
|
||||
}
|
||||
|
||||
oauth.InvalidateCachedUser(ctx, user.ID)
|
||||
auth.InvalidateCachedUser(ctx, user.ID)
|
||||
|
||||
fmt.Println("成功重置密码!")
|
||||
fmt.Printf("用户名: %s\n", user.Username)
|
||||
@@ -4,14 +4,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/testhelper"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
||||
userdomain "Wavelet/plugins/domain/user"
|
||||
)
|
||||
|
||||
func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
|
||||
@@ -19,7 +19,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
// Seed test user
|
||||
user := model.User{
|
||||
user := userdomain.User{
|
||||
ID: 1001,
|
||||
Username: "testuser1",
|
||||
Nickname: "Test User 1",
|
||||
@@ -33,7 +33,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
// Create access token to test invalidation/deletion
|
||||
token := model.AccessToken{
|
||||
token := userdomain.AccessToken{
|
||||
ID: 1,
|
||||
UserID: user.ID,
|
||||
Name: "testtoken",
|
||||
@@ -74,7 +74,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify password in DB
|
||||
var dbUser model.User
|
||||
var dbUser userdomain.User
|
||||
if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil {
|
||||
t.Fatalf("failed to query user from DB: %v", err)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
|
||||
|
||||
// Verify token deleted
|
||||
var count int64
|
||||
dbConn.Model(&model.AccessToken{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
dbConn.Model(&userdomain.AccessToken{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("expected access tokens to be deleted, got %d", count)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func TestResetPasswdCmd_WithUserAndRandomPassword(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
// Seed test user
|
||||
user := model.User{
|
||||
user := userdomain.User{
|
||||
ID: 1002,
|
||||
Username: "testuser2",
|
||||
Nickname: "Test User 2",
|
||||
@@ -142,7 +142,7 @@ func TestResetPasswdCmd_WithUserAndRandomPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify password in DB (should be updated and not equal to old one)
|
||||
var dbUser model.User
|
||||
var dbUser userdomain.User
|
||||
if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil {
|
||||
t.Fatalf("failed to query user from DB: %v", err)
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func TestResetPasswdCmd_InteractiveMode(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
// Seed test user
|
||||
user := model.User{
|
||||
user := userdomain.User{
|
||||
ID: 1003,
|
||||
Username: "testuser3",
|
||||
Nickname: "Test User 3",
|
||||
@@ -217,7 +217,7 @@ func TestResetPasswdCmd_InteractiveMode(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify user password changed in DB
|
||||
var dbUser model.User
|
||||
var dbUser userdomain.User
|
||||
if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil {
|
||||
t.Fatalf("failed to query user from DB: %v", err)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/core/extpoints"
|
||||
"Wavelet/pkg/buildinfo"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/pkg/trace"
|
||||
"Wavelet/plugins/infra/config"
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const traceShutdownTimeout = 10 * time.Second
|
||||
|
||||
type hostConfig struct {
|
||||
App struct {
|
||||
AppName string `config:"app_name" env:"APP_NAME" default:"Wavelet"`
|
||||
Env string `config:"env" env:"APP_ENV" default:"production"`
|
||||
NodeID int64 `config:"node_id" env:"APP_NODE_ID" default:"1"`
|
||||
Addr string `config:"addr" env:"APP_ADDR" default:"127.0.0.1:3000"`
|
||||
} `config:"app"`
|
||||
Log struct {
|
||||
Level string `config:"level" env:"LOG_LEVEL" default:"info"`
|
||||
Format string `config:"format" env:"LOG_FORMAT" default:"json"`
|
||||
Output string `config:"output" env:"LOG_OUTPUT" default:"stdout"`
|
||||
FilePath string `config:"file_path" env:"LOG_FILE_PATH" default:"./logs/app.log"`
|
||||
MaxSize int `config:"max_size" env:"LOG_MAX_SIZE" default:"100"`
|
||||
MaxAge int `config:"max_age" env:"LOG_MAX_AGE" default:"30"`
|
||||
MaxBackups int `config:"max_backups" env:"LOG_MAX_BACKUPS" default:"10"`
|
||||
Compress bool `config:"compress" env:"LOG_COMPRESS" default:"true"`
|
||||
} `config:"log"`
|
||||
OTel struct {
|
||||
SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE" default:"1.0"`
|
||||
TracerName string `config:"tracer_name" env:"OTEL_TRACER_NAME" default:"github.com/Rain-kl/Wavelet"`
|
||||
} `config:"otel"`
|
||||
}
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "wavelet",
|
||||
PersistentPreRun: func(_ *cobra.Command, _ []string) {
|
||||
src, err := config.NewSource()
|
||||
if err != nil {
|
||||
log.Fatalf("[CMD] load config source failed: %v", err)
|
||||
}
|
||||
var cfg hostConfig
|
||||
reg := extpoints.NewConfigRegistry(src)
|
||||
_ = reg.Declare("host", extpoints.ConfigBinding{Target: &cfg})
|
||||
if err := reg.Resolve(); err != nil {
|
||||
log.Fatalf("[CMD] resolve host config failed: %v", err)
|
||||
}
|
||||
_ = reg.Bind("", &cfg)
|
||||
|
||||
// Initialize idgen snowflake generator
|
||||
if err := idgen.Init(cfg.App.NodeID); err != nil {
|
||||
log.Fatalf("[CMD] init idgen failed: %v", err)
|
||||
}
|
||||
|
||||
logger.Init(logger.Config{
|
||||
Level: cfg.Log.Level,
|
||||
Format: cfg.Log.Format,
|
||||
Output: cfg.Log.Output,
|
||||
FilePath: cfg.Log.FilePath,
|
||||
MaxSize: cfg.Log.MaxSize,
|
||||
MaxAge: cfg.Log.MaxAge,
|
||||
MaxBackups: cfg.Log.MaxBackups,
|
||||
Compress: cfg.Log.Compress,
|
||||
})
|
||||
trace.Init(trace.Config{
|
||||
AppName: cfg.App.AppName,
|
||||
SamplingRate: cfg.OTel.SamplingRate,
|
||||
TracerName: cfg.OTel.TracerName,
|
||||
})
|
||||
},
|
||||
PersistentPostRun: func(_ *cobra.Command, _ []string) {
|
||||
shutdownTraceProvider()
|
||||
},
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
// 无参数时默认以融合模式启动所有服务
|
||||
allCmd.Run(allCmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
func shutdownTraceProvider() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), traceShutdownTimeout)
|
||||
defer cancel()
|
||||
trace.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Version = buildinfo.Version
|
||||
rootCmd.CompletionOptions.DisableDefaultCmd = true
|
||||
|
||||
// 集中将子命令注册到根命令,以解决 Cobra 的 unknown command 校验限制
|
||||
rootCmd.AddCommand(allCmd, apiCmd, workerCmd, schedulerCmd)
|
||||
}
|
||||
|
||||
// Execute 执行根命令
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
log.Fatalf("[CMD] execute failed; %s\n", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var schedulerCmd = &cobra.Command{
|
||||
Use: "scheduler",
|
||||
Short: "wavelet Scheduler",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
runProfileApp(core.ProfileSchedule, "scheduler", false)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Wavelet/core"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
goldenRoot = "/Users/ryan/Code/Go/OpenFlare"
|
||||
goldCommit = "9f79fb99"
|
||||
goldGooseVersion = int64(202608090003)
|
||||
sampleZoneDomain = "l3-upgrade-golden.example"
|
||||
goldMigrateWait = 75 * time.Second
|
||||
legacyPluginStamp = "openflare/legacy"
|
||||
serverPluginStamp = "server"
|
||||
)
|
||||
|
||||
var (
|
||||
goldBinOnce sync.Once
|
||||
goldBinPath string
|
||||
goldSrcDir string
|
||||
goldBinErr error
|
||||
)
|
||||
|
||||
func TestUpgradeFromGolden(t *testing.T) {
|
||||
t.Run("sqlite", func(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
dbPath := filepath.Join(tmp, "a.db")
|
||||
runGoldenAPI(t, tmp, goldSQLiteEnv(t, tmp, dbPath), func() bool {
|
||||
return sqliteReady(dbPath)
|
||||
})
|
||||
assertUpgradeFromGolden(t, upgradeDB{
|
||||
sqlitePath: dbPath,
|
||||
source: cordisSQLiteSource(t, dbPath),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpgradePostgresFromGolden(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("TEST_PG_DSN"))
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_PG_DSN is not set")
|
||||
}
|
||||
|
||||
host, port, user, pass, adminDB, sslMode := parsePostgresDSN(t, dsn)
|
||||
adminDSN := postgresDSN(host, port, user, pass, adminDB, sslMode)
|
||||
admin := openInspectDB(t, "", adminDSN)
|
||||
t.Cleanup(func() { _ = admin.Close() })
|
||||
|
||||
dbName := fmt.Sprintf("of_l3_%d", time.Now().UnixNano())
|
||||
if !safePGIdent(dbName) {
|
||||
t.Fatalf("generated database name %q is not a safe identifier", dbName)
|
||||
}
|
||||
if _, err := admin.Exec("CREATE DATABASE " + dbName); err != nil {
|
||||
t.Fatalf("CREATE DATABASE %s: %v", dbName, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = admin.Exec(`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, dbName)
|
||||
_, _ = admin.Exec("DROP DATABASE IF EXISTS " + dbName)
|
||||
})
|
||||
|
||||
tmp := t.TempDir()
|
||||
testDSN := postgresDSN(host, port, user, pass, dbName, sslMode)
|
||||
runGoldenAPI(t, tmp, goldPostgresEnv(t, tmp, host, port, user, pass, dbName, sslMode), func() bool {
|
||||
return postgresReady(testDSN)
|
||||
})
|
||||
assertUpgradeFromGolden(t, upgradeDB{
|
||||
pgDSN: testDSN,
|
||||
source: cordisPostgresSource(t, host, port, user, pass, dbName, sslMode),
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpgradePostgresFromExistingDump(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("TEST_PG_EXISTING_DSN"))
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_PG_EXISTING_DSN is not set")
|
||||
}
|
||||
|
||||
host, port, user, pass, dbName, sslMode := parsePostgresDSN(t, dsn)
|
||||
spec := upgradeDB{
|
||||
pgDSN: dsn,
|
||||
source: cordisPostgresSource(t, host, port, user, pass, dbName, sslMode),
|
||||
}
|
||||
|
||||
inspect := openInspectDB(t, "", spec.pgDSN)
|
||||
beforeCounts := countNamedTables(t, inspect, productionCountTables)
|
||||
beforeTables := listPublicTables(t, inspect)
|
||||
_ = inspect.Close()
|
||||
|
||||
assertUpgradeFromGolden(t, spec)
|
||||
|
||||
inspect = openInspectDB(t, "", spec.pgDSN)
|
||||
defer func() { _ = inspect.Close() }()
|
||||
afterCounts := countNamedTables(t, inspect, productionCountTables)
|
||||
for _, name := range productionCountTables {
|
||||
if afterCounts[name] < beforeCounts[name] {
|
||||
t.Errorf("row count dropped for %s: before %d after %d", name, beforeCounts[name], afterCounts[name])
|
||||
}
|
||||
}
|
||||
afterTables := listPublicTables(t, inspect)
|
||||
for name := range beforeTables {
|
||||
if !afterTables[name] {
|
||||
t.Errorf("table %s dropped", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"w_schema_versions", "w_message_channels", "w_message_bindings", "w_message_pairing_codes"} {
|
||||
if !afterTables[name] {
|
||||
t.Errorf("expected upgrade to create %s", name)
|
||||
}
|
||||
}
|
||||
var n int
|
||||
if err := inspect.QueryRow(`SELECT COUNT(*) FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhparent WHERE c.relname IN ('of_node_access_logs', 'w_user_access_logs')`).Scan(&n); err != nil {
|
||||
t.Fatalf("count partitions: %v", err)
|
||||
}
|
||||
if n < 8 {
|
||||
t.Errorf("partition children = %d, want at least 8", n)
|
||||
}
|
||||
}
|
||||
|
||||
var productionCountTables = []string{
|
||||
"of_zones", "of_zone_domains", "of_proxy_routes", "of_nodes", "of_origins",
|
||||
"of_tls_certificates", "of_waf_rule_groups", "of_pages_projects",
|
||||
"w_users", "w_schedules", "w_system_configs", "w_templates", "w_uploads",
|
||||
"of_node_access_logs", "w_user_access_logs",
|
||||
}
|
||||
|
||||
func countNamedTables(t *testing.T, db *sql.DB, tables []string) map[string]int {
|
||||
t.Helper()
|
||||
out := make(map[string]int, len(tables))
|
||||
for _, name := range tables {
|
||||
if !safePGIdent(name) {
|
||||
t.Fatalf("unsafe table name %q", name)
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM " + name).Scan(&n); err != nil {
|
||||
t.Fatalf("count %s: %v", name, err)
|
||||
}
|
||||
out[name] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func listPublicTables(t *testing.T, db *sql.DB) map[string]bool {
|
||||
t.Helper()
|
||||
rows, err := db.Query(`SELECT tablename FROM pg_tables WHERE schemaname = 'public'`)
|
||||
if err != nil {
|
||||
t.Fatalf("list public tables: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatalf("scan table name: %v", err)
|
||||
}
|
||||
out[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("list public tables: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type upgradeDB struct {
|
||||
sqlitePath string
|
||||
pgDSN string
|
||||
source core.ConfigSource
|
||||
}
|
||||
|
||||
func assertUpgradeFromGolden(t *testing.T, spec upgradeDB) {
|
||||
t.Helper()
|
||||
|
||||
inspect := openInspectDB(t, spec.sqlitePath, spec.pgDSN)
|
||||
before := dumpOfSchema(t, inspect, spec.pgDSN != "")
|
||||
insertSQL := `INSERT INTO of_zones (domain) VALUES (?)`
|
||||
if spec.pgDSN != "" {
|
||||
insertSQL = `INSERT INTO of_zones (domain) VALUES ($1)`
|
||||
}
|
||||
if _, err := inspect.Exec(insertSQL, sampleZoneDomain); err != nil {
|
||||
t.Fatalf("insert sample of_zones row: %v", err)
|
||||
}
|
||||
_ = inspect.Close()
|
||||
|
||||
app := cordisPrepare(t, spec.source)
|
||||
legacyRows := schemaPluginRows(t, spec, legacyPluginStamp)
|
||||
assertStampedUpgrade(t, spec, before, legacyRows)
|
||||
if err := app.Context().Dispose(); err != nil {
|
||||
t.Fatalf("dispose first app: %v", err)
|
||||
}
|
||||
|
||||
app2 := cordisPrepare(t, spec.source)
|
||||
t.Cleanup(func() { _ = app2.Context().Dispose() })
|
||||
if got := schemaPluginRows(t, spec, legacyPluginStamp); got != legacyRows {
|
||||
t.Fatalf("second Prepare increased %s rows: got %d, want %d", legacyPluginStamp, got, legacyRows)
|
||||
}
|
||||
assertStampedUpgrade(t, spec, before, legacyRows)
|
||||
}
|
||||
|
||||
func cordisPrepare(t *testing.T, src core.ConfigSource) *core.App {
|
||||
t.Helper()
|
||||
app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(src))
|
||||
if err := app.Prepare(); err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
if err := app.ApplyPlugins(); err != nil {
|
||||
t.Fatalf("ApplyPlugins: %v", err)
|
||||
}
|
||||
if err := app.RunMigrations(); err != nil {
|
||||
t.Fatalf("RunMigrations: %v", err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func assertStampedUpgrade(t *testing.T, spec upgradeDB, before map[string][]string, legacyRows int) {
|
||||
t.Helper()
|
||||
db := openInspectDB(t, spec.sqlitePath, spec.pgDSN)
|
||||
defer func() { _ = db.Close() }()
|
||||
postgres := spec.pgDSN != ""
|
||||
|
||||
if got := gooseMaxVersion(t, db); got != goldGooseVersion {
|
||||
t.Errorf("goose_db_version max = %d, want %d", got, goldGooseVersion)
|
||||
}
|
||||
if legacyRows < 2 {
|
||||
t.Errorf("w_schema_versions %s rows = %d, want at least 2 (0 and %d)", legacyPluginStamp, legacyRows, goldGooseVersion)
|
||||
}
|
||||
if !pluginHasVersion(t, db, postgres, legacyPluginStamp, 0) {
|
||||
t.Errorf("missing w_schema_versions (%s, 0)", legacyPluginStamp)
|
||||
}
|
||||
if !pluginHasVersion(t, db, postgres, legacyPluginStamp, goldGooseVersion) {
|
||||
t.Errorf("missing w_schema_versions (%s, %d)", legacyPluginStamp, goldGooseVersion)
|
||||
}
|
||||
if !pluginHasVersion(t, db, postgres, serverPluginStamp, 1) {
|
||||
t.Errorf("missing w_schema_versions (%s, 1)", serverPluginStamp)
|
||||
}
|
||||
|
||||
var domain string
|
||||
q := `SELECT domain FROM of_zones WHERE domain = ?`
|
||||
if postgres {
|
||||
q = `SELECT domain FROM of_zones WHERE domain = $1`
|
||||
}
|
||||
if err := db.QueryRow(q, sampleZoneDomain).Scan(&domain); err != nil {
|
||||
t.Errorf("sample of_zones row missing after upgrade: %v", err)
|
||||
}
|
||||
|
||||
after := dumpOfSchema(t, db, postgres)
|
||||
for table, cols := range before {
|
||||
got, ok := after[table]
|
||||
if !ok {
|
||||
t.Errorf("of_* table %s dropped", table)
|
||||
continue
|
||||
}
|
||||
have := make(map[string]bool, len(got))
|
||||
for _, c := range got {
|
||||
have[c] = true
|
||||
}
|
||||
for _, c := range cols {
|
||||
if !have[c] {
|
||||
t.Errorf("of_* column %s.%s dropped", table, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runGoldenAPI(t *testing.T, workDir string, env []string, ready func() bool) {
|
||||
t.Helper()
|
||||
bin := buildGoldenBinary(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), goldMigrateWait)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, bin, "api")
|
||||
cmd.Dir = workDir
|
||||
cmd.Env = env
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start golden api: %v", err)
|
||||
}
|
||||
|
||||
waitErr := make(chan error, 1)
|
||||
go func() { waitErr <- cmd.Wait() }()
|
||||
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if ready() {
|
||||
killGolden(cmd)
|
||||
<-waitErr
|
||||
return
|
||||
}
|
||||
select {
|
||||
case err := <-waitErr:
|
||||
if ready() {
|
||||
return
|
||||
}
|
||||
t.Fatalf("golden api exited before goose %d: %v\n%s", goldGooseVersion, err, out.String())
|
||||
case <-ctx.Done():
|
||||
killGolden(cmd)
|
||||
<-waitErr
|
||||
t.Fatalf("timeout waiting for golden goose %d\n%s", goldGooseVersion, out.String())
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func killGolden(cmd *exec.Cmd) {
|
||||
if cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
|
||||
func buildGoldenBinary(t *testing.T) string {
|
||||
t.Helper()
|
||||
goldBinOnce.Do(func() {
|
||||
src, err := os.MkdirTemp("", "of-gold-src-")
|
||||
if err != nil {
|
||||
goldBinErr = err
|
||||
return
|
||||
}
|
||||
archive := exec.Command("git", "-C", goldenRoot, "archive", goldCommit)
|
||||
extract := exec.Command("tar", "-x", "-C", src)
|
||||
pipe, err := archive.StdoutPipe()
|
||||
if err != nil {
|
||||
goldBinErr = fmt.Errorf("gold archive pipe: %w", err)
|
||||
return
|
||||
}
|
||||
extract.Stdin = pipe
|
||||
var archiveErr, extractErr bytes.Buffer
|
||||
archive.Stderr = &archiveErr
|
||||
extract.Stderr = &extractErr
|
||||
if err := archive.Start(); err != nil {
|
||||
goldBinErr = fmt.Errorf("git archive %s: %w", goldCommit, err)
|
||||
return
|
||||
}
|
||||
if err := extract.Start(); err != nil {
|
||||
_ = archive.Process.Kill()
|
||||
goldBinErr = fmt.Errorf("extract gold %s: %w", goldCommit, err)
|
||||
return
|
||||
}
|
||||
if err := extract.Wait(); err != nil {
|
||||
_ = archive.Wait()
|
||||
goldBinErr = fmt.Errorf("extract gold %s: %w\n%s", goldCommit, err, extractErr.String())
|
||||
return
|
||||
}
|
||||
if err := archive.Wait(); err != nil {
|
||||
goldBinErr = fmt.Errorf("git archive %s: %w\n%s", goldCommit, err, archiveErr.String())
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(src, "main.go")); err != nil {
|
||||
goldBinErr = fmt.Errorf("gold %s at %s: %w", goldCommit, src, err)
|
||||
return
|
||||
}
|
||||
goldSrcDir = src
|
||||
|
||||
dir, err := os.MkdirTemp("", "of-gold-bin-")
|
||||
if err != nil {
|
||||
goldBinErr = err
|
||||
return
|
||||
}
|
||||
out := filepath.Join(dir, "gold")
|
||||
cmd := exec.Command("go", "build", "-o", out, ".")
|
||||
cmd.Dir = src
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
if err := cmd.Run(); err != nil {
|
||||
goldBinErr = fmt.Errorf("go build golden %s: %w\n%s", goldCommit, err, buf.String())
|
||||
return
|
||||
}
|
||||
goldBinPath = out
|
||||
})
|
||||
if goldBinErr != nil {
|
||||
t.Fatalf("%v", goldBinErr)
|
||||
}
|
||||
return goldBinPath
|
||||
}
|
||||
|
||||
func copyGoldConfig(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
buildGoldenBinary(t)
|
||||
dst := filepath.Join(dir, "config.yaml")
|
||||
src, err := os.Open(filepath.Join(goldSrcDir, "config.example.yaml")) //nolint:gosec // extracted gold snapshot
|
||||
if err != nil {
|
||||
t.Fatalf("open golden config.example.yaml: %v", err)
|
||||
}
|
||||
defer func() { _ = src.Close() }()
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) //nolint:gosec // test temp file
|
||||
if err != nil {
|
||||
t.Fatalf("create temp config.yaml: %v", err)
|
||||
}
|
||||
if _, err := io.Copy(out, src); err != nil {
|
||||
_ = out.Close()
|
||||
t.Fatalf("copy golden config: %v", err)
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
t.Fatalf("close temp config.yaml: %v", err)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func goldSQLiteEnv(t *testing.T, dir, dbPath string) []string {
|
||||
t.Helper()
|
||||
cfg := copyGoldConfig(t, dir)
|
||||
addr := freeLocalAddr(t)
|
||||
return filteredGoldEnv(
|
||||
"CONFIG_PATH="+cfg,
|
||||
"SQLITE_PATH="+dbPath,
|
||||
"DB_ENABLED=false",
|
||||
"REDIS_ENABLED=false",
|
||||
"CLICKHOUSE_ENABLED=false",
|
||||
"APP_ENV=testing",
|
||||
"APP_ADDR="+addr,
|
||||
)
|
||||
}
|
||||
|
||||
func goldPostgresEnv(t *testing.T, dir, host string, port int, user, pass, dbName, sslMode string) []string {
|
||||
t.Helper()
|
||||
cfg := copyGoldConfig(t, dir)
|
||||
addr := freeLocalAddr(t)
|
||||
return filteredGoldEnv(
|
||||
"CONFIG_PATH="+cfg,
|
||||
"DB_ENABLED=true",
|
||||
"DB_HOST="+host,
|
||||
"DB_PORT="+strconv.Itoa(port),
|
||||
"DB_USERNAME="+user,
|
||||
"DB_PASSWORD="+pass,
|
||||
"DB_NAME="+dbName,
|
||||
"DB_SSL_MODE="+sslMode,
|
||||
"REDIS_ENABLED=false",
|
||||
"CLICKHOUSE_ENABLED=false",
|
||||
"APP_ENV=testing",
|
||||
"APP_ADDR="+addr,
|
||||
)
|
||||
}
|
||||
|
||||
func filteredGoldEnv(extra ...string) []string {
|
||||
drop := map[string]bool{
|
||||
"CONFIG_PATH": true,
|
||||
"SQLITE_PATH": true,
|
||||
"DB_ENABLED": true,
|
||||
"DB_HOST": true,
|
||||
"DB_PORT": true,
|
||||
"DB_USERNAME": true,
|
||||
"DB_PASSWORD": true,
|
||||
"DB_NAME": true,
|
||||
"DB_SSL_MODE": true,
|
||||
"REDIS_ENABLED": true,
|
||||
"REDIS_ADDR": true,
|
||||
"CLICKHOUSE_ENABLED": true,
|
||||
"CLICKHOUSE_HOST": true,
|
||||
"APP_ENV": true,
|
||||
"APP_ADDR": true,
|
||||
}
|
||||
env := make([]string, 0, len(os.Environ())+len(extra))
|
||||
for _, kv := range os.Environ() {
|
||||
k, _, _ := strings.Cut(kv, "=")
|
||||
if drop[k] {
|
||||
continue
|
||||
}
|
||||
env = append(env, kv)
|
||||
}
|
||||
return append(env, extra...)
|
||||
}
|
||||
|
||||
func freeLocalAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen for free port: %v", err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
_ = ln.Close()
|
||||
return addr
|
||||
}
|
||||
|
||||
func cordisSQLiteSource(t *testing.T, dbPath string) core.ConfigSource {
|
||||
t.Helper()
|
||||
return core.NewMapSource(map[string]any{
|
||||
"app": map[string]any{
|
||||
"addr": "127.0.0.1:0",
|
||||
"env": "testing",
|
||||
},
|
||||
"redis": map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
"clickhouse": map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
"database": map[string]any{
|
||||
"enabled": false,
|
||||
"sqlite_path": dbPath,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func cordisPostgresSource(t *testing.T, host string, port int, user, pass, dbName, sslMode string) core.ConfigSource {
|
||||
t.Helper()
|
||||
return core.NewMapSource(map[string]any{
|
||||
"app": map[string]any{
|
||||
"addr": "127.0.0.1:0",
|
||||
"env": "testing",
|
||||
},
|
||||
"redis": map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
"clickhouse": map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
"database": map[string]any{
|
||||
"enabled": true,
|
||||
"host": host,
|
||||
"port": port,
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"database": dbName,
|
||||
"ssl_mode": sslMode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteReady(path string) bool {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return false
|
||||
}
|
||||
gdb, err := gorm.Open(sqlite.Open("file:"+path+"?mode=ro&_pragma=busy_timeout(1000)"), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
return migratedReady(sqlDB, false)
|
||||
}
|
||||
|
||||
func postgresReady(dsn string) bool {
|
||||
gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
return migratedReady(sqlDB, true)
|
||||
}
|
||||
|
||||
func migratedReady(db *sql.DB, postgres bool) bool {
|
||||
if gooseMaxVersionSilent(db) != goldGooseVersion {
|
||||
return false
|
||||
}
|
||||
var n int
|
||||
var err error
|
||||
if postgres {
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'of_nodes'`).Scan(&n)
|
||||
} else {
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'of_nodes'`).Scan(&n)
|
||||
}
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
func openInspectDB(t *testing.T, sqlitePath, pgDSN string) *sql.DB {
|
||||
t.Helper()
|
||||
var gdb *gorm.DB
|
||||
var err error
|
||||
if pgDSN != "" {
|
||||
gdb, err = gorm.Open(postgres.Open(pgDSN), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
} else {
|
||||
gdb, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("open inspect db: %v", err)
|
||||
}
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("inspect sql.DB: %v", err)
|
||||
}
|
||||
return sqlDB
|
||||
}
|
||||
|
||||
func dumpOfSchema(t *testing.T, db *sql.DB, postgres bool) map[string][]string {
|
||||
t.Helper()
|
||||
tables := ofTables(t, db, postgres)
|
||||
out := make(map[string][]string, len(tables))
|
||||
for _, table := range tables {
|
||||
out[table] = ofColumns(t, db, postgres, table)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("no of_* tables in golden database")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ofTables(t *testing.T, db *sql.DB, postgres bool) []string {
|
||||
t.Helper()
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if postgres {
|
||||
rows, err = db.Query(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE 'of_%' ORDER BY tablename`)
|
||||
} else {
|
||||
rows, err = db.Query(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'of_%' ORDER BY name`)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("list of_* tables: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var tables []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatalf("scan of_* table: %v", err)
|
||||
}
|
||||
tables = append(tables, name)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("list of_* tables: %v", err)
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
func ofColumns(t *testing.T, db *sql.DB, postgres bool, table string) []string {
|
||||
t.Helper()
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if postgres {
|
||||
rows, err = db.Query(`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position`, table)
|
||||
} else {
|
||||
rows, err = db.Query(`SELECT name FROM pragma_table_info(?)`, table)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("list columns for %s: %v", table, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var cols []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatalf("scan column for %s: %v", table, err)
|
||||
}
|
||||
cols = append(cols, name)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("list columns for %s: %v", table, err)
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
func gooseMaxVersion(t *testing.T, db *sql.DB) int64 {
|
||||
t.Helper()
|
||||
v := gooseMaxVersionSilent(db)
|
||||
if v < 0 {
|
||||
t.Fatal("read goose_db_version max failed")
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func gooseMaxVersionSilent(db *sql.DB) int64 {
|
||||
var v int64
|
||||
if err := db.QueryRow(`SELECT COALESCE(MAX(version_id), 0) FROM goose_db_version`).Scan(&v); err != nil {
|
||||
return -1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func schemaPluginRows(t *testing.T, spec upgradeDB, pluginID string) int {
|
||||
t.Helper()
|
||||
db := openInspectDB(t, spec.sqlitePath, spec.pgDSN)
|
||||
defer func() { _ = db.Close() }()
|
||||
q := `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = ?`
|
||||
if spec.pgDSN != "" {
|
||||
q = `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = $1`
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRow(q, pluginID).Scan(&n); err != nil {
|
||||
t.Fatalf("count w_schema_versions %s: %v", pluginID, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func pluginHasVersion(t *testing.T, db *sql.DB, postgres bool, pluginID string, version int64) bool {
|
||||
t.Helper()
|
||||
q := `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = ? AND version_id = ?`
|
||||
if postgres {
|
||||
q = `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = $1 AND version_id = $2`
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRow(q, pluginID, version).Scan(&n); err != nil {
|
||||
t.Fatalf("lookup w_schema_versions (%s, %d): %v", pluginID, version, err)
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func parsePostgresDSN(t *testing.T, dsn string) (host string, port int, user, pass, dbName, sslMode string) {
|
||||
t.Helper()
|
||||
u, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("TEST_PG_DSN: %v", err)
|
||||
}
|
||||
host = u.Hostname()
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
port = 5432
|
||||
if p := u.Port(); p != "" {
|
||||
port, err = strconv.Atoi(p)
|
||||
if err != nil {
|
||||
t.Fatalf("TEST_PG_DSN port: %v", err)
|
||||
}
|
||||
}
|
||||
if u.User != nil {
|
||||
user = u.User.Username()
|
||||
pass, _ = u.User.Password()
|
||||
}
|
||||
dbName = strings.Trim(u.Path, "/")
|
||||
if dbName == "" {
|
||||
dbName = "postgres"
|
||||
}
|
||||
sslMode = u.Query().Get("sslmode")
|
||||
if sslMode == "" {
|
||||
sslMode = "disable"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func postgresDSN(host string, port int, user, pass, dbName, sslMode string) string {
|
||||
u := &url.URL{
|
||||
Scheme: "postgres",
|
||||
Host: net.JoinHostPort(host, strconv.Itoa(port)),
|
||||
Path: dbName,
|
||||
}
|
||||
if user != "" {
|
||||
u.User = url.UserPassword(user, pass)
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("sslmode", sslMode)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
var pgIdent = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`)
|
||||
|
||||
func safePGIdent(name string) bool {
|
||||
return pgIdent.MatchString(name)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var workerCmd = &cobra.Command{
|
||||
Use: "worker",
|
||||
Short: "wavelet Worker",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
runProfileApp(core.ProfileWorker, "worker", false)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultShutdownTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// AppOption configures an App instance during construction.
|
||||
type AppOption func(*App)
|
||||
|
||||
// WithContext sets a custom root Context for the App.
|
||||
func WithContext(ctx *Context) AppOption {
|
||||
return func(a *App) {
|
||||
if ctx != nil {
|
||||
a.ctx = ctx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithProfile sets the runtime profile for the App.
|
||||
func WithProfile(profile Profile) AppOption {
|
||||
return func(a *App) {
|
||||
a.profile = normalizeProfile(profile)
|
||||
}
|
||||
}
|
||||
|
||||
// WithPlugins registers initial plugins for the App.
|
||||
func WithPlugins(plugins ...Plugin) AppOption {
|
||||
return func(a *App) {
|
||||
a.Use(plugins...)
|
||||
}
|
||||
}
|
||||
|
||||
// WithMigrationEngine sets the database migration engine for the App.
|
||||
func WithMigrationEngine(engine MigrationEngine) AppOption {
|
||||
return func(a *App) {
|
||||
a.migrationEngine = engine
|
||||
}
|
||||
}
|
||||
|
||||
// WithMigrationRunner sets the migration runner function for the App.
|
||||
func WithMigrationRunner(runner MigrationRunner) AppOption {
|
||||
return func(a *App) {
|
||||
a.migrationEngine = runner
|
||||
}
|
||||
}
|
||||
|
||||
// WithMigrationBaseline registers a hook the migration engine runs after the
|
||||
// shared version table exists and before any plugin Up.
|
||||
func WithMigrationBaseline(fn func(*Context) error) AppOption {
|
||||
return func(a *App) {
|
||||
a.migrationBaseline = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithShutdownTimeout sets the fallback timeout for graceful application shutdown.
|
||||
func WithShutdownTimeout(timeout time.Duration) AppOption {
|
||||
return func(a *App) {
|
||||
if timeout > 0 {
|
||||
a.shutdownTimeout = timeout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithConfigSource installs the raw configuration source adapter, typically built by an
|
||||
// infrastructure package outside the kernel, before any plugin is applied.
|
||||
func WithConfigSource(src ConfigSource) AppOption {
|
||||
return func(a *App) {
|
||||
if src == nil {
|
||||
return
|
||||
}
|
||||
// Installed during Prepare so the option order, including WithContext, is irrelevant.
|
||||
a.configSource = src
|
||||
}
|
||||
}
|
||||
|
||||
// WithConfigDecl lets the composition root declare the configuration it reads itself,
|
||||
// so host-level values take part in conflict validation and the redacted report. The
|
||||
// bindings are registered during Prepare, so option order does not matter.
|
||||
func WithConfigDecl(pluginID string, bindings ...ConfigBinding) AppOption {
|
||||
return func(a *App) {
|
||||
if len(bindings) == 0 {
|
||||
return
|
||||
}
|
||||
if a.hostDeclOwner == "" {
|
||||
a.hostDeclOwner = pluginID
|
||||
}
|
||||
a.hostDeclBindings = append(a.hostDeclBindings, bindings...)
|
||||
}
|
||||
}
|
||||
|
||||
// App is the unified assembly entrypoint and runtime aspect dispatcher of the Cordis micro-kernel.
|
||||
// It manages plugin collection, dependency mounting, migration execution, profile-based driver startup,
|
||||
// and graceful signal-driven LIFO shutdown.
|
||||
type App struct {
|
||||
mu sync.RWMutex
|
||||
ctx *Context
|
||||
profile Profile
|
||||
plugins []Plugin
|
||||
pluginMap map[string]Plugin
|
||||
fibers []*Fiber
|
||||
fiberMap map[string]*Fiber
|
||||
applied bool
|
||||
running bool
|
||||
startedDrivers []Driver
|
||||
migrationEngine MigrationEngine
|
||||
migrationBaseline func(*Context) error
|
||||
shutdownTimeout time.Duration
|
||||
configSource ConfigSource
|
||||
hostDeclOwner string
|
||||
hostDeclBindings []ConfigBinding
|
||||
prepared bool
|
||||
applyErr error
|
||||
}
|
||||
|
||||
// NewApp creates a new Cordis application instance with default options.
|
||||
func NewApp(opts ...AppOption) *App {
|
||||
app := &App{
|
||||
ctx: NewContext(context.Background()),
|
||||
profile: ProfileAll,
|
||||
pluginMap: make(map[string]Plugin),
|
||||
fiberMap: make(map[string]*Fiber),
|
||||
shutdownTimeout: defaultShutdownTimeout,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(app)
|
||||
}
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// Context returns the root micro-kernel Context of the application.
|
||||
func (a *App) Context() *Context {
|
||||
return a.ctx
|
||||
}
|
||||
|
||||
// Profile returns the current runtime profile of the application.
|
||||
func (a *App) Profile() Profile {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.profile
|
||||
}
|
||||
|
||||
// WithProfile sets the application runtime profile and returns the App for fluent chaining.
|
||||
func (a *App) WithProfile(profile Profile) *App {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.profile = normalizeProfile(profile)
|
||||
return a
|
||||
}
|
||||
|
||||
// SetProfile sets the application runtime profile.
|
||||
func (a *App) SetProfile(profile Profile) *App {
|
||||
return a.WithProfile(profile)
|
||||
}
|
||||
|
||||
// Use registers one or more plugins into the application in registration order.
|
||||
// Duplicate plugins (by Name) update existing registrations in-place to preserve order.
|
||||
func (a *App) Use(plugins ...Plugin) *App {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
for _, p := range plugins {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
name := p.Name()
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := a.pluginMap[name]; exists {
|
||||
for i, existing := range a.plugins {
|
||||
if existing.Name() == name {
|
||||
a.plugins[i] = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if existingFiber, ok := a.fiberMap[name]; ok {
|
||||
existingFiber.plugin = p
|
||||
}
|
||||
} else {
|
||||
a.plugins = append(a.plugins, p)
|
||||
f := NewFiber(a.ctx, p)
|
||||
a.fibers = append(a.fibers, f)
|
||||
a.fiberMap[name] = f
|
||||
}
|
||||
a.pluginMap[name] = p
|
||||
|
||||
if gated, ok := p.(ConfigGatedPlugin); ok && a.applyErr == nil {
|
||||
// Gates are evaluated before Apply, so their keys must be declared at mount time.
|
||||
a.applyErr = a.ctx.Config().Declare(name, gated.DeclareConfig()...)
|
||||
}
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// Plugins returns a copy of all registered plugins in registration order.
|
||||
func (a *App) Plugins() []Plugin {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
res := make([]Plugin, len(a.plugins))
|
||||
copy(res, a.plugins)
|
||||
return res
|
||||
}
|
||||
|
||||
// Plugin retrieves a registered plugin by its unique name.
|
||||
func (a *App) Plugin(name string) (Plugin, bool) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
p, ok := a.pluginMap[name]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Fibers returns a copy of all plugin Fibers.
|
||||
func (a *App) Fibers() []*Fiber {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
res := make([]*Fiber, len(a.fibers))
|
||||
copy(res, a.fibers)
|
||||
return res
|
||||
}
|
||||
|
||||
// Fiber retrieves a Fiber by its unique plugin name.
|
||||
func (a *App) Fiber(name string) (*Fiber, bool) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
f, ok := a.fiberMap[name]
|
||||
return f, ok
|
||||
}
|
||||
|
||||
// SetMigrationEngine sets the migration engine for the application.
|
||||
func (a *App) SetMigrationEngine(engine MigrationEngine) *App {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.migrationEngine = engine
|
||||
return a
|
||||
}
|
||||
|
||||
// SetMigrationRunner sets the migration runner function for the application.
|
||||
func (a *App) SetMigrationRunner(runner MigrationRunner) *App {
|
||||
return a.SetMigrationEngine(runner)
|
||||
}
|
||||
|
||||
// Reconcile evaluates all pending Fibers and reactively transitions them to ACTIVE
|
||||
// as their declared dependencies become satisfied.
|
||||
func (a *App) Reconcile() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.reconcileLocked()
|
||||
}
|
||||
|
||||
func (a *App) reconcileLocked() error {
|
||||
if err := a.prepareLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for {
|
||||
progress := false
|
||||
for _, f := range a.fibers {
|
||||
if f.State() != FiberPending {
|
||||
continue
|
||||
}
|
||||
|
||||
gated, skip, err := a.evaluateGateLocked(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if gated && skip {
|
||||
if err := f.Skip(); err != nil {
|
||||
return fmt.Errorf("core: skip gated fiber %q failed: %w", f.Name(), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if f.DependenciesSatisfied(a.ctx) {
|
||||
if err := f.Load(); err != nil {
|
||||
return fmt.Errorf("core: load fiber %q failed: %w", f.Name(), err)
|
||||
}
|
||||
progress = true
|
||||
// Rescan from the head of the Use() list so earlier pending
|
||||
// plugins run before later ones that became ready in this pass.
|
||||
break
|
||||
}
|
||||
}
|
||||
if !progress {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var unsatisfied []string
|
||||
for _, f := range a.fibers {
|
||||
if f.State() == FiberPending {
|
||||
unsatisfied = append(unsatisfied, fmt.Sprintf("%s (waiting for %v)", f.Name(), f.Dependencies()))
|
||||
}
|
||||
}
|
||||
if len(unsatisfied) > 0 {
|
||||
return fmt.Errorf("core: unsatisfied dependencies for plugins: %s", strings.Join(unsatisfied, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// evaluateGateLocked reports whether a configuration-gated plugin is excluded by the
|
||||
// resolved values. Plugins that do not implement the gate interface are never skipped.
|
||||
func (a *App) evaluateGateLocked(f *Fiber) (gated bool, skip bool, err error) {
|
||||
gatedPlugin, ok := f.plugin.(ConfigGatedPlugin)
|
||||
if !ok {
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
view := a.ctx.Config()
|
||||
if !view.Resolved() {
|
||||
return true, false, fmt.Errorf(
|
||||
"core: plugin %q is configuration-gated but the App has no ConfigSource; "+
|
||||
"pass core.WithConfigSource or remove DeclareConfig", f.Name())
|
||||
}
|
||||
|
||||
return true, !gatedPlugin.ConfigEnabled(view), nil
|
||||
}
|
||||
|
||||
// ApplyPlugins applies all registered plugins on the application Context via reactive reconciliation.
|
||||
// It is idempotent and only applies plugins once per App instance.
|
||||
func (a *App) ApplyPlugins() error {
|
||||
a.mu.Lock()
|
||||
if a.applied {
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
a.applied = true
|
||||
|
||||
declaredErr, prepareErr := a.applyErr, a.prepareLocked()
|
||||
a.mu.Unlock()
|
||||
|
||||
if declaredErr != nil {
|
||||
return declaredErr
|
||||
}
|
||||
if prepareErr != nil {
|
||||
return prepareErr
|
||||
}
|
||||
|
||||
return a.Reconcile()
|
||||
}
|
||||
|
||||
// Prepare resolves declared configuration and establishes the resolution barrier that
|
||||
// gates and plugin Bind calls depend on. It is idempotent and runs implicitly from
|
||||
// ApplyPlugins; callers that need resolved values earlier — for example to size a
|
||||
// shutdown budget — invoke it explicitly right after mounting plugins.
|
||||
func (a *App) Prepare() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.applyErr != nil {
|
||||
return a.applyErr
|
||||
}
|
||||
return a.prepareLocked()
|
||||
}
|
||||
|
||||
// prepareLocked installs the injected source, registers host declarations and resolves
|
||||
// every declared key once. An App without a ConfigSource leaves configuration unused,
|
||||
// so kernel-level usage stays opt-in for embedders that configure nothing.
|
||||
func (a *App) prepareLocked() error {
|
||||
if a.prepared {
|
||||
return nil
|
||||
}
|
||||
if a.configSource != nil {
|
||||
config := a.ctx.Config()
|
||||
config.SetSource(a.configSource)
|
||||
|
||||
if err := config.Declare(a.hostDeclOwner, a.hostDeclBindings...); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := config.Resolve(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
a.ctx.setMigrationBaseline(a.migrationBaseline)
|
||||
a.prepared = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShutdownTimeout returns the graceful shutdown budget for the application.
|
||||
func (a *App) ShutdownTimeout() time.Duration {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.shutdownTimeout
|
||||
}
|
||||
|
||||
// SetShutdownTimeout replaces the graceful shutdown budget, ignoring non-positive
|
||||
// values so a missing configuration key can never shrink the kernel fallback to zero.
|
||||
func (a *App) SetShutdownTimeout(timeout time.Duration) *App {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if timeout > 0 {
|
||||
a.shutdownTimeout = timeout
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// RunMigrations dispatches migration execution across all registered plugin migration entries.
|
||||
func (a *App) RunMigrations() error {
|
||||
entries := a.ctx.Migrations().Entries()
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
a.mu.RLock()
|
||||
engine := a.migrationEngine
|
||||
a.mu.RUnlock()
|
||||
|
||||
if engine == nil {
|
||||
// Attempt to resolve from IoC container
|
||||
if resolved, err := Inject[MigrationEngine](a.ctx); err == nil && resolved != nil {
|
||||
engine = resolved
|
||||
}
|
||||
}
|
||||
|
||||
if engine == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := engine.Migrate(a.ctx, entries); err != nil {
|
||||
return fmt.Errorf("core: migration failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start executes the application boot pipeline:
|
||||
// 1. Applies all registered plugins to populate services, routes, tasks, and drivers.
|
||||
// 2. Dispatches database migrations via MigrationEngine.
|
||||
// 3. Filters and starts drivers matching the active Profile.
|
||||
// 4. Emits "app:ready" on the EventBus.
|
||||
func (a *App) Start(ctx ...context.Context) error {
|
||||
a.mu.Lock()
|
||||
if a.running {
|
||||
a.mu.Unlock()
|
||||
return ErrAppRunning
|
||||
}
|
||||
a.running = true
|
||||
a.mu.Unlock()
|
||||
|
||||
var baseCtx context.Context
|
||||
switch {
|
||||
case len(ctx) > 0 && ctx[0] != nil:
|
||||
baseCtx = ctx[0]
|
||||
case a.ctx != nil:
|
||||
baseCtx = a.ctx.GoContext()
|
||||
default:
|
||||
baseCtx = context.Background()
|
||||
}
|
||||
|
||||
// 1. Apply plugins
|
||||
if err := a.ApplyPlugins(); err != nil {
|
||||
a.mu.Lock()
|
||||
a.running = false
|
||||
a.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Run migrations
|
||||
if err := a.RunMigrations(); err != nil {
|
||||
a.mu.Lock()
|
||||
a.running = false
|
||||
a.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Filter drivers matching active profile
|
||||
a.mu.RLock()
|
||||
prof := a.profile
|
||||
a.mu.RUnlock()
|
||||
|
||||
allDrivers := a.ctx.Drivers()
|
||||
var driversToStart []Driver
|
||||
for _, d := range allDrivers {
|
||||
if matchesProfile(prof, d.Type()) {
|
||||
driversToStart = append(driversToStart, d)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Start matching drivers
|
||||
for _, d := range driversToStart {
|
||||
if err := d.Start(baseCtx); err != nil {
|
||||
// Rollback already started drivers in reverse order
|
||||
a.mu.Lock()
|
||||
started := a.startedDrivers
|
||||
a.startedDrivers = nil
|
||||
a.running = false
|
||||
a.mu.Unlock()
|
||||
|
||||
for i := len(started) - 1; i >= 0; i-- {
|
||||
_ = started[i].Stop(context.Background())
|
||||
}
|
||||
|
||||
return fmt.Errorf("core: start driver %s failed: %w", d.Type(), err)
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.startedDrivers = append(a.startedDrivers, d)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// 5. Emit app:ready event
|
||||
_ = a.ctx.Events().Emit(baseCtx, "app:ready", a)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the application:
|
||||
// 1. Emits "app:stopping" on the EventBus.
|
||||
// 2. Stops all started drivers in LIFO (reverse) order.
|
||||
// 3. Disposes the Context (running registered OnDispose callbacks in LIFO order).
|
||||
// 4. Emits "app:stopped" on the EventBus.
|
||||
func (a *App) Stop(ctx ...context.Context) error {
|
||||
a.mu.Lock()
|
||||
if !a.running {
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
a.running = false
|
||||
started := a.startedDrivers
|
||||
a.startedDrivers = nil
|
||||
timeout := a.shutdownTimeout
|
||||
a.mu.Unlock()
|
||||
|
||||
var shutdownCtx context.Context
|
||||
if len(ctx) > 0 && ctx[0] != nil {
|
||||
shutdownCtx = ctx[0]
|
||||
} else {
|
||||
var cancel context.CancelFunc
|
||||
shutdownCtx, cancel = context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
_ = a.ctx.Events().Emit(shutdownCtx, "app:stopping", a)
|
||||
|
||||
var errs []error
|
||||
|
||||
// 1. Stop drivers in reverse order
|
||||
for i := len(started) - 1; i >= 0; i-- {
|
||||
d := started[i]
|
||||
if err := d.Stop(shutdownCtx); err != nil {
|
||||
errs = append(errs, fmt.Errorf("core: stop driver %s failed: %w", d.Type(), err))
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Unload fibers in reverse order
|
||||
a.mu.RLock()
|
||||
fibers := make([]*Fiber, len(a.fibers))
|
||||
copy(fibers, a.fibers)
|
||||
a.mu.RUnlock()
|
||||
|
||||
for i := len(fibers) - 1; i >= 0; i-- {
|
||||
if err := fibers[i].Unload(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("core: unload fiber %s failed: %w", fibers[i].Name(), err))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Dispose root context
|
||||
if a.ctx != nil && !a.ctx.IsDisposed() {
|
||||
if err := a.ctx.Dispose(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("core: dispose context failed: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
_ = a.ctx.Events().Emit(shutdownCtx, "app:stopped", a)
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Run starts the application and blocks until an OS signal (SIGINT, SIGTERM) or context cancellation is received,
|
||||
// then executes graceful shutdown. It forwards a sigCtx derived from the caller's context to Start.
|
||||
//
|
||||
//nolint:contextcheck // the caller's ctx does reach Start via sigCtx; the rule cannot follow Run's variadic context parameter
|
||||
func (a *App) Run(ctx ...context.Context) error {
|
||||
var parent context.Context
|
||||
switch {
|
||||
case len(ctx) > 0 && ctx[0] != nil:
|
||||
parent = ctx[0]
|
||||
case a.ctx != nil:
|
||||
parent = a.ctx.GoContext()
|
||||
default:
|
||||
parent = context.Background()
|
||||
}
|
||||
|
||||
sigCtx, stopSignals := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
|
||||
defer stopSignals()
|
||||
|
||||
if err := a.Start(sigCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for OS signal or context cancellation
|
||||
<-sigCtx.Done()
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), a.shutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
return a.Stop(shutdownCtx)
|
||||
}
|
||||
|
||||
// IsRunning returns whether the application is currently running.
|
||||
func (a *App) IsRunning() bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.running
|
||||
}
|
||||
|
||||
// StartedDrivers returns a copy of currently running drivers.
|
||||
func (a *App) StartedDrivers() []Driver {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
res := make([]Driver, len(a.startedDrivers))
|
||||
copy(res, a.startedDrivers)
|
||||
return res
|
||||
}
|
||||
|
||||
// ExecuteCLI parses CLI arguments to configure the profile and runs the application.
|
||||
func (a *App) ExecuteCLI(args ...string) error {
|
||||
var ctx context.Context
|
||||
if a.ctx != nil {
|
||||
ctx = a.ctx.GoContext()
|
||||
} else {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return a.ExecuteCLIWithContext(ctx, args...)
|
||||
}
|
||||
|
||||
// ExecuteCLIWithContext parses CLI arguments, configures the profile, and runs the application with the given context.
|
||||
func (a *App) ExecuteCLIWithContext(ctx context.Context, args ...string) error {
|
||||
cliArgs := args
|
||||
if len(cliArgs) == 0 {
|
||||
cliArgs = os.Args[1:]
|
||||
}
|
||||
|
||||
profile := ProfileAll
|
||||
if len(cliArgs) > 0 {
|
||||
first := strings.TrimSpace(cliArgs[0])
|
||||
switch {
|
||||
case strings.HasPrefix(first, "--profile="):
|
||||
profile = Profile(strings.TrimPrefix(first, "--profile="))
|
||||
case strings.HasPrefix(first, "-p="):
|
||||
profile = Profile(strings.TrimPrefix(first, "-p="))
|
||||
case !strings.HasPrefix(first, "-"):
|
||||
profile = Profile(first)
|
||||
}
|
||||
}
|
||||
|
||||
a.WithProfile(profile)
|
||||
return a.Run(ctx)
|
||||
}
|
||||
|
||||
func matchesProfile(profile Profile, dt DriverType) bool {
|
||||
norm := normalizeProfile(profile)
|
||||
switch norm {
|
||||
case ProfileAll, "":
|
||||
return true
|
||||
case ProfileAPI:
|
||||
return dt == DriverTypeHTTP
|
||||
case ProfileWorker:
|
||||
return dt == DriverTypeWorker
|
||||
case ProfileSchedule:
|
||||
return dt == DriverTypeScheduler
|
||||
default:
|
||||
return string(norm) == string(dt)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProfile(p Profile) Profile {
|
||||
switch strings.ToLower(strings.TrimSpace(string(p))) {
|
||||
case "api", "http":
|
||||
return ProfileAPI
|
||||
case "worker":
|
||||
return ProfileWorker
|
||||
case "schedule", "scheduler", "cron":
|
||||
return ProfileSchedule
|
||||
case "all", "fused", "full", "":
|
||||
return ProfileAll
|
||||
default:
|
||||
return p
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/extpoints"
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// appMockDriver is a test driver tracking its start/stop lifecycle.
|
||||
type appMockDriver struct {
|
||||
mu sync.Mutex
|
||||
driverType core.DriverType
|
||||
startCalled bool
|
||||
stopCalled bool
|
||||
startErr error
|
||||
stopErr error
|
||||
}
|
||||
|
||||
func newAppMockDriver(dt core.DriverType) *appMockDriver {
|
||||
return &appMockDriver{driverType: dt}
|
||||
}
|
||||
|
||||
func (m *appMockDriver) Type() core.DriverType {
|
||||
return m.driverType
|
||||
}
|
||||
|
||||
func (m *appMockDriver) Start(_ context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.startErr != nil {
|
||||
return m.startErr
|
||||
}
|
||||
m.startCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *appMockDriver) Stop(_ context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.stopErr != nil {
|
||||
return m.stopErr
|
||||
}
|
||||
m.stopCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *appMockDriver) isStarted() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.startCalled
|
||||
}
|
||||
|
||||
func (m *appMockDriver) isStopped() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.stopCalled
|
||||
}
|
||||
|
||||
// appMockPlugin is a test plugin.
|
||||
type appMockPlugin struct {
|
||||
name string
|
||||
applyFn func(ctx *core.Context) error
|
||||
}
|
||||
|
||||
func (p *appMockPlugin) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *appMockPlugin) Apply(ctx *core.Context) error {
|
||||
if p.applyFn != nil {
|
||||
return p.applyFn(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAppNewAndConfiguration(t *testing.T) {
|
||||
customCtx := core.NewContext(context.Background())
|
||||
p1 := &appMockPlugin{name: "plugin1"}
|
||||
p2 := &appMockPlugin{name: "plugin2"}
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithContext(customCtx),
|
||||
core.WithProfile(core.ProfileAPI),
|
||||
core.WithPlugins(p1, p2),
|
||||
core.WithShutdownTimeout(5*time.Second),
|
||||
)
|
||||
|
||||
assert.Equal(t, customCtx, app.Context())
|
||||
assert.Equal(t, core.ProfileAPI, app.Profile())
|
||||
assert.Len(t, app.Plugins(), 2)
|
||||
|
||||
retrieved, ok := app.Plugin("plugin1")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, p1, retrieved)
|
||||
|
||||
_, ok = app.Plugin("non_existent")
|
||||
assert.False(t, ok)
|
||||
|
||||
// Update existing plugin in-place
|
||||
p1Updated := &appMockPlugin{name: "plugin1"}
|
||||
app.Use(p1Updated, nil)
|
||||
assert.Len(t, app.Plugins(), 2)
|
||||
retrieved, ok = app.Plugin("plugin1")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, p1Updated, retrieved)
|
||||
|
||||
// Test SetProfile
|
||||
app.SetProfile(core.ProfileWorker)
|
||||
assert.Equal(t, core.ProfileWorker, app.Profile())
|
||||
}
|
||||
|
||||
func TestAppProfileDispatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
profile core.Profile
|
||||
expectedHTTP bool
|
||||
expectedWorker bool
|
||||
expectedCron bool
|
||||
expectedCustom bool
|
||||
}{
|
||||
{
|
||||
name: "ProfileAPI only starts HTTP driver",
|
||||
profile: core.ProfileAPI,
|
||||
expectedHTTP: true,
|
||||
expectedWorker: false,
|
||||
expectedCron: false,
|
||||
expectedCustom: false,
|
||||
},
|
||||
{
|
||||
name: "ProfileWorker only starts Worker driver",
|
||||
profile: core.ProfileWorker,
|
||||
expectedHTTP: false,
|
||||
expectedWorker: true,
|
||||
expectedCron: false,
|
||||
expectedCustom: false,
|
||||
},
|
||||
{
|
||||
name: "ProfileSchedule only starts Schedule driver",
|
||||
profile: core.ProfileSchedule,
|
||||
expectedHTTP: false,
|
||||
expectedWorker: false,
|
||||
expectedCron: true,
|
||||
expectedCustom: false,
|
||||
},
|
||||
{
|
||||
name: "Profile 'scheduler' alias starts Schedule driver",
|
||||
profile: core.Profile("scheduler"),
|
||||
expectedHTTP: false,
|
||||
expectedWorker: false,
|
||||
expectedCron: true,
|
||||
expectedCustom: false,
|
||||
},
|
||||
{
|
||||
name: "ProfileAll starts all drivers",
|
||||
profile: core.ProfileAll,
|
||||
expectedHTTP: true,
|
||||
expectedWorker: true,
|
||||
expectedCron: true,
|
||||
expectedCustom: true,
|
||||
},
|
||||
{
|
||||
name: "Custom profile starts custom driver",
|
||||
profile: core.Profile("custom_rpc"),
|
||||
expectedHTTP: false,
|
||||
expectedWorker: false,
|
||||
expectedCron: false,
|
||||
expectedCustom: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
httpD := newAppMockDriver(core.DriverTypeHTTP)
|
||||
workerD := newAppMockDriver(core.DriverTypeWorker)
|
||||
cronD := newAppMockDriver(core.DriverTypeScheduler)
|
||||
customD := newAppMockDriver(core.DriverType("custom_rpc"))
|
||||
|
||||
p := &appMockPlugin{
|
||||
name: "drivers_plugin",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
_ = ctx.RegisterDriver(httpD)
|
||||
_ = ctx.RegisterDriver(workerD)
|
||||
_ = ctx.RegisterDriver(cronD)
|
||||
_ = ctx.RegisterDriver(customD)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(tt.profile),
|
||||
core.WithPlugins(p),
|
||||
)
|
||||
|
||||
err := app.Start(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.expectedHTTP, httpD.isStarted(), "HTTP driver start mismatch")
|
||||
assert.Equal(t, tt.expectedWorker, workerD.isStarted(), "Worker driver start mismatch")
|
||||
assert.Equal(t, tt.expectedCron, cronD.isStarted(), "Cron driver start mismatch")
|
||||
assert.Equal(t, tt.expectedCustom, customD.isStarted(), "Custom driver start mismatch")
|
||||
|
||||
err = app.Stop(context.Background())
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppLifecycleStartStop(t *testing.T) {
|
||||
var stopOrder []string
|
||||
var stopOrderMu sync.Mutex
|
||||
|
||||
httpD := newAppMockDriver(core.DriverTypeHTTP)
|
||||
workerD := newAppMockDriver(core.DriverTypeWorker)
|
||||
|
||||
httpD.stopErr = nil
|
||||
workerD.stopErr = nil
|
||||
|
||||
// Wrap stop to record order
|
||||
origHttpStop := httpD.Stop
|
||||
_ = origHttpStop
|
||||
|
||||
p := &appMockPlugin{
|
||||
name: "test_plugin",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
_ = ctx.RegisterDriver(httpD)
|
||||
_ = ctx.RegisterDriver(workerD)
|
||||
|
||||
ctx.OnDispose(func() error {
|
||||
stopOrderMu.Lock()
|
||||
stopOrder = append(stopOrder, "ctx_disposer")
|
||||
stopOrderMu.Unlock()
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.ProfileAll),
|
||||
core.WithPlugins(p),
|
||||
)
|
||||
|
||||
var readyReceived, stoppingReceived, stoppedReceived bool
|
||||
app.Context().Events().On("app:ready", func() {
|
||||
readyReceived = true
|
||||
})
|
||||
app.Context().Events().On("app:stopping", func() {
|
||||
stoppingReceived = true
|
||||
})
|
||||
app.Context().Events().On("app:stopped", func() {
|
||||
stoppedReceived = true
|
||||
})
|
||||
|
||||
err := app.Start(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, app.IsRunning())
|
||||
assert.Len(t, app.StartedDrivers(), 2)
|
||||
assert.True(t, readyReceived)
|
||||
|
||||
err = app.Stop(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.False(t, app.IsRunning())
|
||||
assert.Empty(t, app.StartedDrivers())
|
||||
assert.True(t, stoppingReceived)
|
||||
assert.True(t, stoppedReceived)
|
||||
|
||||
assert.True(t, httpD.isStopped())
|
||||
assert.True(t, workerD.isStopped())
|
||||
assert.True(t, app.Context().IsDisposed())
|
||||
|
||||
stopOrderMu.Lock()
|
||||
assert.Contains(t, stopOrder, "ctx_disposer")
|
||||
stopOrderMu.Unlock()
|
||||
}
|
||||
|
||||
func TestAppStartDriverFailureRollback(t *testing.T) {
|
||||
driver1 := newAppMockDriver(core.DriverTypeHTTP)
|
||||
driver2 := newAppMockDriver(core.DriverTypeWorker)
|
||||
driver2.startErr = errors.New("worker listen port conflict")
|
||||
driver3 := newAppMockDriver(core.DriverTypeScheduler)
|
||||
|
||||
p := &appMockPlugin{
|
||||
name: "fail_driver_plugin",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
_ = ctx.RegisterDriver(driver1)
|
||||
_ = ctx.RegisterDriver(driver2)
|
||||
_ = ctx.RegisterDriver(driver3)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.ProfileAll),
|
||||
core.WithPlugins(p),
|
||||
)
|
||||
|
||||
err := app.Start(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "worker listen port conflict")
|
||||
assert.False(t, app.IsRunning())
|
||||
|
||||
// Driver 1 was started then rolled back (stopped)
|
||||
assert.True(t, driver1.isStarted())
|
||||
assert.True(t, driver1.isStopped())
|
||||
|
||||
// Driver 3 was never started
|
||||
assert.False(t, driver3.isStarted())
|
||||
}
|
||||
|
||||
func TestAppMigrationEngineExecution(t *testing.T) {
|
||||
var migratedEntries []extpoints.MigrationEntry
|
||||
runner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
|
||||
migratedEntries = entries
|
||||
return nil
|
||||
})
|
||||
|
||||
sqlFS := fstest.MapFS{
|
||||
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE users(id int);")},
|
||||
}
|
||||
|
||||
p := &appMockPlugin{
|
||||
name: "auth",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
ctx.Migrations().Register("auth", sqlFS)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.ProfileAll),
|
||||
core.WithPlugins(p),
|
||||
core.WithMigrationRunner(runner),
|
||||
)
|
||||
|
||||
err := app.Start(context.Background())
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = app.Stop(context.Background()) }()
|
||||
|
||||
require.Len(t, migratedEntries, 1)
|
||||
assert.Equal(t, "auth", migratedEntries[0].PluginID)
|
||||
}
|
||||
|
||||
func TestAppMigrationEngineFromIoCContainer(t *testing.T) {
|
||||
var executed bool
|
||||
runner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
|
||||
executed = true
|
||||
return nil
|
||||
})
|
||||
|
||||
sqlFS := fstest.MapFS{
|
||||
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE logs(id int);")},
|
||||
}
|
||||
|
||||
p := &appMockPlugin{
|
||||
name: "logstore",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
ctx.Migrations().Register("logstore", sqlFS)
|
||||
core.Provide[core.MigrationEngine](ctx, runner)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.ProfileAll),
|
||||
core.WithPlugins(p),
|
||||
)
|
||||
|
||||
err := app.Start(context.Background())
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = app.Stop(context.Background()) }()
|
||||
|
||||
assert.True(t, executed)
|
||||
}
|
||||
|
||||
func TestAppRunContextCancellation(t *testing.T) {
|
||||
d := newAppMockDriver(core.DriverTypeHTTP)
|
||||
p := &appMockPlugin{
|
||||
name: "http_plugin",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
return ctx.RegisterDriver(d)
|
||||
},
|
||||
}
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.ProfileAPI),
|
||||
core.WithPlugins(p),
|
||||
core.WithShutdownTimeout(1*time.Second),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- app.Run(ctx)
|
||||
}()
|
||||
|
||||
// Wait for app and driver to become ready
|
||||
assert.Eventually(t, func() bool {
|
||||
return app.IsRunning() && d.isStarted()
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, app.IsRunning())
|
||||
assert.True(t, d.isStopped())
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("app.Run did not terminate upon context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppExecuteCLI(t *testing.T) {
|
||||
// Test CLI argument parsing logic
|
||||
tests := []struct {
|
||||
args []string
|
||||
expectedProfile core.Profile
|
||||
}{
|
||||
{args: []string{"api"}, expectedProfile: core.ProfileAPI},
|
||||
{args: []string{"worker"}, expectedProfile: core.ProfileWorker},
|
||||
{args: []string{"scheduler"}, expectedProfile: core.ProfileSchedule},
|
||||
{args: []string{"schedule"}, expectedProfile: core.ProfileSchedule},
|
||||
{args: []string{"all"}, expectedProfile: core.ProfileAll},
|
||||
{args: []string{"--profile=worker"}, expectedProfile: core.ProfileWorker},
|
||||
{args: []string{"-p=api"}, expectedProfile: core.ProfileAPI},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.args[0], func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
// Use custom root context to control cancellation
|
||||
customApp := core.NewApp(core.WithContext(core.NewContext(ctx)))
|
||||
_ = customApp.ExecuteCLI(tt.args...)
|
||||
assert.Equal(t, tt.expectedProfile, customApp.Profile())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppIdempotencyAndErrorStates(t *testing.T) {
|
||||
app := core.NewApp()
|
||||
|
||||
// Double start returns error
|
||||
err := app.Start(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
err = app.Start(context.Background())
|
||||
assert.ErrorIs(t, err, core.ErrAppRunning)
|
||||
|
||||
// Stop clears running state
|
||||
err = app.Stop(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Double stop succeeds
|
||||
err = app.Stop(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Plugin apply failure
|
||||
failPlugin := &appMockPlugin{
|
||||
name: "failing_plugin",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
return errors.New("plugin init boom")
|
||||
},
|
||||
}
|
||||
app2 := core.NewApp(core.WithPlugins(failPlugin))
|
||||
err = app2.Start(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "plugin init boom")
|
||||
assert.False(t, app2.IsRunning())
|
||||
|
||||
// Migration failure
|
||||
migFailRunner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
|
||||
return errors.New("sql migrate error")
|
||||
})
|
||||
sqlFS := fstest.MapFS{
|
||||
"migrations/001.sql": &fstest.MapFile{Data: []byte("...")},
|
||||
}
|
||||
migPlugin := &appMockPlugin{
|
||||
name: "db_plugin",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
ctx.Migrations().Register("db_plugin", sqlFS)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
app3 := core.NewApp(
|
||||
core.WithPlugins(migPlugin),
|
||||
core.WithMigrationRunner(migFailRunner),
|
||||
)
|
||||
err = app3.Start(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "sql migrate error")
|
||||
assert.False(t, app3.IsRunning())
|
||||
}
|
||||
|
||||
// newGateSource builds a configuration source whose only key decides the test gates.
|
||||
func newGateSource(enabled bool) *mapSource {
|
||||
return &mapSource{
|
||||
values: map[string]any{"gate.enabled": enabled},
|
||||
env: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppPrepareResolvesThenGatesDuringReconcile(t *testing.T) {
|
||||
primary := &gatedPlugin{name: "cache", enabled: true}
|
||||
fallback := &gatedPlugin{name: "cache_memory", enabled: false}
|
||||
|
||||
app := core.NewApp(core.WithConfigSource(newGateSource(true)))
|
||||
app.Use(primary, fallback)
|
||||
require.NoError(t, app.Prepare())
|
||||
|
||||
cacheFiber, ok := app.Fiber("cache")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, core.FiberPending, cacheFiber.State(), "Prepare only builds the resolution barrier")
|
||||
assert.True(t, app.Context().Config().Resolved())
|
||||
|
||||
require.NoError(t, app.Reconcile())
|
||||
|
||||
assert.Equal(t, core.FiberActive, cacheFiber.State())
|
||||
|
||||
memoryFiber, ok := app.Fiber("cache_memory")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, memoryFiber.State())
|
||||
assert.False(t, fallback.applied, "the gated-out provider must never reach Apply")
|
||||
}
|
||||
|
||||
func TestAppGatesPluginsMountedAfterPrepare(t *testing.T) {
|
||||
app := core.NewApp(core.WithConfigSource(newGateSource(true)))
|
||||
require.NoError(t, app.Prepare())
|
||||
|
||||
late := &gatedPlugin{name: "cache_memory", enabled: false}
|
||||
app.Use(late)
|
||||
require.NoError(t, app.Reconcile())
|
||||
|
||||
fiber, ok := app.Fiber("cache_memory")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, fiber.State(),
|
||||
"plugins mounted after Prepare must still be gated")
|
||||
}
|
||||
|
||||
func TestAppApplyPluginsGatesImplicitly(t *testing.T) {
|
||||
app := core.NewApp(core.WithConfigSource(newGateSource(false)))
|
||||
app.Use(&gatedPlugin{name: "cache", enabled: true})
|
||||
|
||||
require.NoError(t, app.ApplyPlugins())
|
||||
|
||||
fiber, ok := app.Fiber("cache")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, fiber.State(),
|
||||
"ApplyPlugins must resolve and gate without an explicit Prepare call")
|
||||
}
|
||||
|
||||
func TestAppPrepareReportsConfigurationErrors(t *testing.T) {
|
||||
src := &mapSource{
|
||||
values: map[string]any{"gate.enabled": "yes"},
|
||||
env: map[string]string{},
|
||||
}
|
||||
app := core.NewApp(core.WithConfigSource(src))
|
||||
app.Use(&gatedPlugin{name: "cache", enabled: true})
|
||||
|
||||
err := app.Prepare()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "gate.enabled")
|
||||
}
|
||||
|
||||
func TestAppGatedPluginWithoutConfigSourceFailsFast(t *testing.T) {
|
||||
app := core.NewApp()
|
||||
app.Use(&gatedPlugin{name: "cache", enabled: true})
|
||||
|
||||
err := app.ApplyPlugins()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cache")
|
||||
assert.Contains(t, err.Error(), "ConfigSource")
|
||||
}
|
||||
|
||||
func TestAppSetShutdownTimeoutIgnoresNonPositive(t *testing.T) {
|
||||
app := core.NewApp()
|
||||
|
||||
app.SetShutdownTimeout(0)
|
||||
assert.Equal(t, 10*time.Second, app.ShutdownTimeout(), "zero must not shrink the kernel fallback")
|
||||
|
||||
app.SetShutdownTimeout(45 * time.Second)
|
||||
assert.Equal(t, 45*time.Second, app.ShutdownTimeout())
|
||||
}
|
||||
|
||||
func TestWithMigrationBaselineVisibleAfterPrepare(t *testing.T) {
|
||||
var called bool
|
||||
fn := func(*core.Context) error {
|
||||
called = true
|
||||
return nil
|
||||
}
|
||||
|
||||
app := core.NewApp(core.WithMigrationBaseline(fn))
|
||||
require.Nil(t, app.Context().MigrationBaseline(), "baseline must be copied during Prepare")
|
||||
|
||||
require.NoError(t, app.Prepare())
|
||||
|
||||
got := app.Context().MigrationBaseline()
|
||||
require.NotNil(t, got, "Prepare must copy the baseline onto the root Context")
|
||||
require.NoError(t, got(app.Context()))
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestWithMigrationBaselineRunsBeforeEngineMigrate(t *testing.T) {
|
||||
var order []string
|
||||
engine := core.MigrationRunner(func(ctx *core.Context, _ []extpoints.MigrationEntry) error {
|
||||
order = append(order, "engine")
|
||||
if ctx.MigrationBaseline() == nil {
|
||||
t.Fatal("baseline must be visible on context inside Migrate")
|
||||
}
|
||||
return ctx.MigrationBaseline()(ctx)
|
||||
})
|
||||
|
||||
sqlFS := fstest.MapFS{
|
||||
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("-- +goose Up\nSELECT 1;\n")},
|
||||
}
|
||||
app := core.NewApp(
|
||||
core.WithMigrationEngine(engine),
|
||||
core.WithMigrationBaseline(func(*core.Context) error {
|
||||
order = append(order, "baseline")
|
||||
return nil
|
||||
}),
|
||||
core.WithPlugins(&appMockPlugin{
|
||||
name: "t",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
ctx.Migrations().Register("t", sqlFS)
|
||||
return nil
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
require.NoError(t, app.Start(context.Background()))
|
||||
defer func() { _ = app.Stop(context.Background()) }()
|
||||
|
||||
assert.Equal(t, []string{"engine", "baseline"}, order)
|
||||
}
|
||||
|
||||
func TestWithMigrationBaselineNilByDefault(t *testing.T) {
|
||||
app := core.NewApp()
|
||||
require.NoError(t, app.Prepare())
|
||||
assert.Nil(t, app.Context().MigrationBaseline())
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core
|
||||
|
||||
import "context"
|
||||
|
||||
type appContextKey struct{}
|
||||
|
||||
// WithAppContext attaches the micro-kernel Context to a standard context.Context
|
||||
// so request and worker handlers can Inject services without package-level setters.
|
||||
func WithAppContext(ctx context.Context, app *Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if app == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, appContextKey{}, app.Root())
|
||||
}
|
||||
|
||||
// AppContext extracts the micro-kernel Context from ctx, if present.
|
||||
func AppContext(ctx context.Context) *Context {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
if c, ok := ctx.(*Context); ok {
|
||||
return c
|
||||
}
|
||||
app, _ := ctx.Value(appContextKey{}).(*Context)
|
||||
return app
|
||||
}
|
||||
|
||||
// InjectFrom resolves T from ctx when it carries a micro-kernel Context
|
||||
// (*Context itself, or a value attached by WithAppContext).
|
||||
func InjectFrom[T any](ctx context.Context) (T, error) {
|
||||
var zero T
|
||||
app := AppContext(ctx)
|
||||
if app == nil {
|
||||
return zero, ErrNilContext
|
||||
}
|
||||
return Inject[T](app)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"Wavelet/core/extpoints"
|
||||
)
|
||||
|
||||
// ConfigGet reads one resolved configuration value with its declared type. It is the
|
||||
// generic counterpart of the fallback accessors on ConfigView, used when a caller must
|
||||
// distinguish "unset" from "set to the zero value".
|
||||
func ConfigGet[T any](view extpoints.ConfigView, key string) (T, error) {
|
||||
var zero T
|
||||
if view == nil {
|
||||
return zero, extpoints.ErrConfigNotResolved
|
||||
}
|
||||
|
||||
raw, ok := view.Value(key)
|
||||
if !ok {
|
||||
return zero, fmt.Errorf("%w: %s", extpoints.ErrConfigUnknownKey, key)
|
||||
}
|
||||
|
||||
value, ok := raw.(T)
|
||||
if !ok {
|
||||
return zero, fmt.Errorf("%w: key %q holds %T, want %T", extpoints.ErrConfigType, key, raw, zero)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// MapSource implements ConfigSource backed by an in-memory map, ideal for unit tests.
|
||||
type MapSource struct {
|
||||
values map[string]any
|
||||
env map[string]string
|
||||
}
|
||||
|
||||
// NewMapSource creates a new MapSource with the provided key-value mappings.
|
||||
func NewMapSource(values map[string]any) *MapSource {
|
||||
vals := make(map[string]any, len(values))
|
||||
for k, v := range values {
|
||||
vals[k] = v
|
||||
}
|
||||
return &MapSource{
|
||||
values: vals,
|
||||
env: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup returns the value at the given path, supporting both flat keys and nested maps.
|
||||
func (m *MapSource) Lookup(path string) (any, bool) {
|
||||
if m == nil || m.values == nil {
|
||||
return nil, false
|
||||
}
|
||||
if v, ok := m.values[path]; ok {
|
||||
return v, true
|
||||
}
|
||||
parts := strings.Split(path, ".")
|
||||
var cur any = m.values
|
||||
for _, part := range parts {
|
||||
mCur, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
cur, ok = mCur[part]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return cur, true
|
||||
}
|
||||
|
||||
// LookupEnv returns the environment variable value.
|
||||
func (m *MapSource) LookupEnv(name string) (string, bool) {
|
||||
if m == nil || m.env == nil {
|
||||
return "", false
|
||||
}
|
||||
v, ok := m.env[name]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// SetEnv sets an environment variable for testing.
|
||||
func (m *MapSource) SetEnv(name, value string) {
|
||||
if m.env == nil {
|
||||
m.env = make(map[string]string)
|
||||
}
|
||||
m.env[name] = value
|
||||
}
|
||||
|
||||
// Describe describes the MapSource.
|
||||
func (m *MapSource) Describe() string {
|
||||
return "<map source>"
|
||||
}
|
||||
|
||||
// WithConfigValues returns an AppOption that installs a MapSource with the given key-value mappings.
|
||||
func WithConfigValues(values map[string]any) AppOption {
|
||||
return WithConfigSource(NewMapSource(values))
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/extpoints"
|
||||
)
|
||||
|
||||
// mapSource implements extpoints.ConfigSource over static maps.
|
||||
type mapSource struct {
|
||||
values map[string]any
|
||||
env map[string]string
|
||||
}
|
||||
|
||||
func (m *mapSource) Lookup(path string) (any, bool) {
|
||||
v, ok := m.values[path]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (m *mapSource) LookupEnv(name string) (string, bool) {
|
||||
v, ok := m.env[name]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (m *mapSource) Describe() string { return "map" }
|
||||
|
||||
type otelConfig struct {
|
||||
SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"`
|
||||
}
|
||||
|
||||
// newOtelRegistry declares the otel section against a source carrying the given file values.
|
||||
func newOtelRegistry(t *testing.T, values map[string]any) extpoints.ConfigExtension {
|
||||
t.Helper()
|
||||
|
||||
r := extpoints.NewConfigRegistry(&mapSource{values: values, env: map[string]string{}})
|
||||
require.NoError(t, r.Declare("host", extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
return r
|
||||
}
|
||||
|
||||
func TestConfigGetReturnsDeclaredType(t *testing.T) {
|
||||
view := newOtelRegistry(t, map[string]any{"otel.sampling_rate": 0.25})
|
||||
|
||||
rate, err := core.ConfigGet[float64](view, "otel.sampling_rate")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0.25, rate)
|
||||
}
|
||||
|
||||
func TestConfigGetRejectsTypeMismatch(t *testing.T) {
|
||||
view := newOtelRegistry(t, map[string]any{"otel.sampling_rate": 0.25})
|
||||
|
||||
text, err := core.ConfigGet[string](view, "otel.sampling_rate")
|
||||
require.ErrorIs(t, err, extpoints.ErrConfigType)
|
||||
assert.Empty(t, text)
|
||||
}
|
||||
|
||||
func TestConfigGetRejectsUndeclaredKey(t *testing.T) {
|
||||
view := newOtelRegistry(t, nil)
|
||||
|
||||
_, err := core.ConfigGet[float64](view, "otel.unregistered")
|
||||
require.ErrorIs(t, err, extpoints.ErrConfigUnknownKey)
|
||||
}
|
||||
|
||||
func TestConfigGetRejectsNilView(t *testing.T) {
|
||||
_, err := core.ConfigGet[float64](nil, "otel.sampling_rate")
|
||||
require.ErrorIs(t, err, extpoints.ErrConfigNotResolved)
|
||||
}
|
||||
|
||||
func TestContextConfigIsSharedAcrossForks(t *testing.T) {
|
||||
ctx := core.NewContext(nil)
|
||||
child := ctx.Fork()
|
||||
|
||||
require.NotNil(t, ctx.Config())
|
||||
assert.Same(t, ctx.Config(), child.Config(), "configuration declarations are process-wide facts")
|
||||
|
||||
require.NoError(t, child.Config().Declare("cache",
|
||||
extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}}))
|
||||
|
||||
declared := false
|
||||
for _, entry := range ctx.Config().Entries() {
|
||||
declared = declared || entry.Key == "otel.sampling_rate"
|
||||
}
|
||||
assert.True(t, declared, "a declaration made in a plugin scope must be visible to the root")
|
||||
assert.False(t, ctx.Config().Resolved())
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package core provides the micro-kernel service bus, generic IoC container, and runtime extensions.
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Container manages service registration and resolution using Go reflection and generics.
|
||||
type Container struct {
|
||||
mu sync.RWMutex
|
||||
parent *Container
|
||||
services map[reflect.Type]any
|
||||
interfaceCache map[reflect.Type]any
|
||||
listeners map[reflect.Type][]func(any)
|
||||
}
|
||||
|
||||
// NewContainer creates a new IoC container instance with an optional parent container.
|
||||
func NewContainer(parent *Container) *Container {
|
||||
return &Container{
|
||||
parent: parent,
|
||||
services: make(map[reflect.Type]any),
|
||||
interfaceCache: make(map[reflect.Type]any),
|
||||
listeners: make(map[reflect.Type][]func(any)),
|
||||
}
|
||||
}
|
||||
|
||||
func isNil(i any) bool {
|
||||
if i == nil {
|
||||
return true
|
||||
}
|
||||
v := reflect.ValueOf(i)
|
||||
switch v.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.UnsafePointer, reflect.Interface, reflect.Slice:
|
||||
return v.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Container) remove(targetType reflect.Type) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.services, targetType)
|
||||
c.interfaceCache = make(map[reflect.Type]any)
|
||||
}
|
||||
|
||||
// Provide registers a typed service implementation into the Context hierarchy's root IoC container.
|
||||
func Provide[T any](ctx *Context, service T) {
|
||||
if ctx == nil {
|
||||
panic("core: nil context provided to Provide")
|
||||
}
|
||||
if isNil(service) {
|
||||
panic("core: cannot provide nil service")
|
||||
}
|
||||
|
||||
targetType := reflect.TypeFor[T]()
|
||||
targetContainer := ctx.Root().Container()
|
||||
targetContainer.provide(targetType, service)
|
||||
|
||||
ctx.OnDispose(func() error {
|
||||
targetContainer.remove(targetType)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ProvideScoped registers a typed service implementation strictly in the local Context container.
|
||||
func ProvideScoped[T any](ctx *Context, service T) {
|
||||
if ctx == nil {
|
||||
panic("core: nil context provided to ProvideScoped")
|
||||
}
|
||||
if isNil(service) {
|
||||
panic("core: cannot provide nil service")
|
||||
}
|
||||
|
||||
targetType := reflect.TypeFor[T]()
|
||||
targetContainer := ctx.Container()
|
||||
targetContainer.provide(targetType, service)
|
||||
|
||||
ctx.OnDispose(func() error {
|
||||
targetContainer.remove(targetType)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Container) provide(targetType reflect.Type, service any) {
|
||||
c.mu.Lock()
|
||||
c.services[targetType] = service
|
||||
c.interfaceCache = make(map[reflect.Type]any)
|
||||
|
||||
// Collect any matching listeners to invoke outside the lock
|
||||
var callbacks []func(any)
|
||||
svcType := reflect.TypeOf(service)
|
||||
for lType, cbs := range c.listeners {
|
||||
if lType == targetType || (lType.Kind() == reflect.Interface && svcType.Implements(lType)) {
|
||||
callbacks = append(callbacks, cbs...)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
for _, cb := range callbacks {
|
||||
cb(service)
|
||||
}
|
||||
}
|
||||
|
||||
// Inject resolves a registered service of type T from the Context.
|
||||
func Inject[T any](ctx *Context) (T, error) {
|
||||
var zero T
|
||||
if ctx == nil {
|
||||
return zero, ErrNilContext
|
||||
}
|
||||
|
||||
targetType := reflect.TypeFor[T]()
|
||||
val, err := ctx.Container().resolve(targetType)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
typedVal, ok := val.(T)
|
||||
if !ok {
|
||||
return zero, fmt.Errorf("%w: cannot cast %T to %v", ErrServiceNotFound, val, targetType)
|
||||
}
|
||||
return typedVal, nil
|
||||
}
|
||||
|
||||
func (c *Container) resolve(targetType reflect.Type) (any, error) {
|
||||
c.mu.RLock()
|
||||
// 1. Direct type match
|
||||
if val, ok := c.services[targetType]; ok {
|
||||
c.mu.RUnlock()
|
||||
return val, nil
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
// 2. Interface assignment scan & cache
|
||||
if targetType.Kind() == reflect.Interface {
|
||||
if val, found := c.resolveInterface(targetType); found {
|
||||
return val, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to parent container
|
||||
if c.parent != nil {
|
||||
return c.parent.resolve(targetType)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%w: %v", ErrServiceNotFound, targetType)
|
||||
}
|
||||
|
||||
func (c *Container) resolveInterface(targetType reflect.Type) (any, bool) {
|
||||
c.mu.RLock()
|
||||
if val, ok := c.interfaceCache[targetType]; ok {
|
||||
c.mu.RUnlock()
|
||||
return val, true
|
||||
}
|
||||
|
||||
var matched any
|
||||
for _, val := range c.services {
|
||||
if reflect.TypeOf(val).Implements(targetType) {
|
||||
matched = val
|
||||
break
|
||||
}
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
if matched == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.interfaceCache == nil {
|
||||
c.interfaceCache = make(map[reflect.Type]any)
|
||||
}
|
||||
c.interfaceCache[targetType] = matched
|
||||
c.mu.Unlock()
|
||||
return matched, true
|
||||
}
|
||||
|
||||
// MustInject resolves a service of type T or panics if the service is not found.
|
||||
func MustInject[T any](ctx *Context) T {
|
||||
s, err := Inject[T](ctx)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("core: failed to inject service %v: %v", reflect.TypeFor[T](), err))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Has returns true if a service of type T is registered and resolvable in the Context.
|
||||
func Has[T any](ctx *Context) bool {
|
||||
_, err := Inject[T](ctx)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Using executes the given function synchronously if the required dependency is ready.
|
||||
func Using[T1 any](ctx *Context, fn func(s1 T1)) error {
|
||||
s1, err := Inject[T1](ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrServiceNotReady, err)
|
||||
}
|
||||
fn(s1)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Using2 executes the given function synchronously if both required dependencies are ready.
|
||||
func Using2[T1, T2 any](ctx *Context, fn func(s1 T1, s2 T2)) error {
|
||||
s1, err1 := Inject[T1](ctx)
|
||||
s2, err2 := Inject[T2](ctx)
|
||||
if err := errors.Join(err1, err2); err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrServiceNotReady, err)
|
||||
}
|
||||
fn(s1, s2)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Using3 executes the given function synchronously if all 3 required dependencies are ready.
|
||||
func Using3[T1, T2, T3 any](ctx *Context, fn func(s1 T1, s2 T2, s3 T3)) error {
|
||||
s1, err1 := Inject[T1](ctx)
|
||||
s2, err2 := Inject[T2](ctx)
|
||||
s3, err3 := Inject[T3](ctx)
|
||||
if err := errors.Join(err1, err2, err3); err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrServiceNotReady, err)
|
||||
}
|
||||
fn(s1, s2, s3)
|
||||
return nil
|
||||
}
|
||||
|
||||
// When registers a reactive hook that is called immediately if T is already provided,
|
||||
// or called as soon as T is provided in the future.
|
||||
//
|
||||
// Listeners are stored on the root container so they observe core.Provide, which
|
||||
// always writes to the root. Registering on a Fiber child container would miss
|
||||
// services provided by plugins that load later.
|
||||
func When[T any](ctx *Context, fn func(s T)) {
|
||||
if ctx == nil {
|
||||
panic("core: nil context provided to When")
|
||||
}
|
||||
|
||||
targetType := reflect.TypeFor[T]()
|
||||
c := ctx.Root().Container()
|
||||
|
||||
// If already ready, execute immediately
|
||||
if s, err := Inject[T](ctx); err == nil {
|
||||
fn(s)
|
||||
}
|
||||
|
||||
// Also register listener for future calls / updates
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.listeners[targetType] = append(c.listeners[targetType], func(val any) {
|
||||
if typed, ok := val.(T); ok {
|
||||
fn(typed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Bind is When with a name that matches plugin wiring: fill a dependency as
|
||||
// soon as the root container provides it.
|
||||
func Bind[T any](ctx *Context, fn func(s T)) {
|
||||
When(ctx, fn)
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"Wavelet/core/extpoints"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Context is the central micro-kernel service bus and runtime lifecycle container.
|
||||
// It embeds Go standard context.Context compatibility, hierarchical scoping,
|
||||
// service resolution, and LIFO disposer teardown.
|
||||
type Context struct {
|
||||
goCtx context.Context
|
||||
cancel context.CancelFunc
|
||||
parent *Context
|
||||
container *Container
|
||||
|
||||
events *EventBus
|
||||
router extpoints.RouterExtension
|
||||
migrations extpoints.MigrationExtension
|
||||
tasks extpoints.TaskExtension
|
||||
schedules extpoints.ScheduleExtension
|
||||
settings extpoints.SettingExtension
|
||||
config extpoints.ConfigExtension
|
||||
|
||||
mu sync.RWMutex
|
||||
children []*Context
|
||||
disposers []Disposer
|
||||
drivers []Driver
|
||||
values map[any]any
|
||||
disposed bool
|
||||
migrationBaseline func(*Context) error
|
||||
}
|
||||
|
||||
// NewContext creates a new root Context wrapping a standard Go context.
|
||||
// If base is nil, context.Background() is used by default.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
func NewContext(base context.Context) *Context {
|
||||
if base == nil {
|
||||
base = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(base)
|
||||
|
||||
return &Context{
|
||||
goCtx: ctx,
|
||||
cancel: cancel,
|
||||
container: NewContainer(nil),
|
||||
events: NewEventBus(),
|
||||
router: extpoints.NewRouterRegistry(),
|
||||
migrations: extpoints.NewMigrationRegistry(),
|
||||
tasks: extpoints.NewTaskRegistry(),
|
||||
schedules: extpoints.NewScheduleRegistry(),
|
||||
settings: extpoints.NewSettingRegistry(),
|
||||
config: extpoints.NewConfigRegistry(nil),
|
||||
values: make(map[any]any),
|
||||
}
|
||||
}
|
||||
|
||||
// Deadline returns the time when work done on behalf of this context should be canceled.
|
||||
func (c *Context) Deadline() (deadline time.Time, ok bool) {
|
||||
return c.goCtx.Deadline()
|
||||
}
|
||||
|
||||
// Done returns a channel that's closed when work done on behalf of this context should be canceled.
|
||||
func (c *Context) Done() <-chan struct{} {
|
||||
return c.goCtx.Done()
|
||||
}
|
||||
|
||||
// Err returns a non-nil error value after Done is closed.
|
||||
func (c *Context) Err() error {
|
||||
return c.goCtx.Err()
|
||||
}
|
||||
|
||||
// Value returns the value associated with key, searching the local values map,
|
||||
// the underlying Go context, and fallback parent Contexts.
|
||||
func (c *Context) Value(key any) any {
|
||||
c.mu.RLock()
|
||||
if v, ok := c.values[key]; ok {
|
||||
c.mu.RUnlock()
|
||||
return v
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
if v := c.goCtx.Value(key); v != nil {
|
||||
return v
|
||||
}
|
||||
|
||||
if c.parent != nil {
|
||||
return c.parent.Value(key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GoContext returns the underlying standard Go context.Context.
|
||||
func (c *Context) GoContext() context.Context {
|
||||
return c.goCtx
|
||||
}
|
||||
|
||||
// Set stores an arbitrary key-value pair in this Context's local storage.
|
||||
func (c *Context) Set(key, val any) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.values == nil {
|
||||
c.values = make(map[any]any)
|
||||
}
|
||||
c.values[key] = val
|
||||
}
|
||||
|
||||
// Get retrieves a key-value pair from this Context's local storage.
|
||||
func (c *Context) Get(key any) (any, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if c.values == nil {
|
||||
return nil, false
|
||||
}
|
||||
v, ok := c.values[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// Container returns the underlying IoC container for this Context.
|
||||
func (c *Context) Container() *Container {
|
||||
return c.container
|
||||
}
|
||||
|
||||
// Parent returns the parent Context, or nil if this is a root Context.
|
||||
func (c *Context) Parent() *Context {
|
||||
return c.parent
|
||||
}
|
||||
|
||||
// Root returns the root Context in the hierarchy.
|
||||
func (c *Context) Root() *Context {
|
||||
curr := c
|
||||
for curr.parent != nil {
|
||||
curr = curr.parent
|
||||
}
|
||||
return curr
|
||||
}
|
||||
|
||||
// Fork creates a child Context with its own scoped IoC container and values,
|
||||
// linked to this Context for hierarchical fallback resolution and cascading teardown.
|
||||
func (c *Context) Fork() *Context {
|
||||
return c.ForkWithContext(c.goCtx)
|
||||
}
|
||||
|
||||
// ForkWithContext creates a child Context using a specific standard Go context.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
func (c *Context) ForkWithContext(base context.Context) *Context {
|
||||
if base == nil {
|
||||
base = c.goCtx
|
||||
}
|
||||
ctx, cancel := context.WithCancel(base)
|
||||
|
||||
child := &Context{
|
||||
goCtx: ctx,
|
||||
cancel: cancel,
|
||||
parent: c,
|
||||
container: NewContainer(c.container),
|
||||
events: c.events,
|
||||
router: c.router,
|
||||
migrations: c.migrations,
|
||||
tasks: c.tasks,
|
||||
schedules: c.schedules,
|
||||
settings: c.settings,
|
||||
config: c.config,
|
||||
values: make(map[any]any),
|
||||
migrationBaseline: c.MigrationBaseline(),
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.children = append(c.children, child)
|
||||
c.mu.Unlock()
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
// Events returns the domain EventBus associated with this Context hierarchy.
|
||||
func (c *Context) Events() *EventBus {
|
||||
return c.events
|
||||
}
|
||||
|
||||
// On registers an event listener on the EventBus and automatically attaches its Disposer
|
||||
// to this Context's teardown stack for automatic revocation when disposed.
|
||||
func (c *Context) On(topic string, handler any) Disposer {
|
||||
disposer := c.events.On(topic, handler)
|
||||
c.OnDispose(disposer)
|
||||
return disposer
|
||||
}
|
||||
|
||||
// Effect registers a reversible side-effect cleanup callback on this Context.
|
||||
func (c *Context) Effect(fn any) {
|
||||
c.OnDispose(fn)
|
||||
}
|
||||
|
||||
// Router returns the scoped RouterExtension registry with automatic disposer tracking.
|
||||
func (c *Context) Router() extpoints.RouterExtension {
|
||||
return newScopedRouterExtension(c, c.router)
|
||||
}
|
||||
|
||||
// Migrations returns the MigrationExtension registry.
|
||||
func (c *Context) Migrations() extpoints.MigrationExtension {
|
||||
return c.migrations
|
||||
}
|
||||
|
||||
// MigrationBaseline returns the hook copied onto this Context during App.Prepare.
|
||||
// Child contexts fall back to their parent so forks still see the root hook.
|
||||
func (c *Context) MigrationBaseline() func(*Context) error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.RLock()
|
||||
fn := c.migrationBaseline
|
||||
c.mu.RUnlock()
|
||||
if fn != nil {
|
||||
return fn
|
||||
}
|
||||
if c.parent != nil {
|
||||
return c.parent.MigrationBaseline()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Context) setMigrationBaseline(fn func(*Context) error) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.migrationBaseline = fn
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Tasks returns the scoped TaskExtension registry with automatic disposer tracking.
|
||||
func (c *Context) Tasks() extpoints.TaskExtension {
|
||||
return newScopedTaskExtension(c, c.tasks)
|
||||
}
|
||||
|
||||
// Task is an alias for Tasks().
|
||||
func (c *Context) Task() extpoints.TaskExtension {
|
||||
return c.Tasks()
|
||||
}
|
||||
|
||||
// Schedules returns the scoped ScheduleExtension registry with automatic disposer tracking.
|
||||
func (c *Context) Schedules() extpoints.ScheduleExtension {
|
||||
return newScopedScheduleExtension(c, c.schedules)
|
||||
}
|
||||
|
||||
// Schedule is an alias for Schedules().
|
||||
func (c *Context) Schedule() extpoints.ScheduleExtension {
|
||||
return c.Schedules()
|
||||
}
|
||||
|
||||
// Settings returns the scoped SettingExtension registry with automatic disposer tracking.
|
||||
func (c *Context) Settings() extpoints.SettingExtension {
|
||||
return newScopedSettingExtension(c, c.settings)
|
||||
}
|
||||
|
||||
// Setting is an alias for Settings().
|
||||
func (c *Context) Setting() extpoints.SettingExtension {
|
||||
return c.Settings()
|
||||
}
|
||||
|
||||
// Config returns the process-level configuration extension point. The registry is
|
||||
// shared by every fork because configuration declarations are global facts, and it
|
||||
// carries no per-scope disposers: values are resolved once before Apply runs.
|
||||
func (c *Context) Config() extpoints.ConfigExtension {
|
||||
return c.config
|
||||
}
|
||||
|
||||
// OnDispose registers a cleanup callback function to be executed when this Context is disposed.
|
||||
// It accepts func() error, func(), or Disposer.
|
||||
func (c *Context) OnDispose(fn any) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var d Disposer
|
||||
switch f := fn.(type) {
|
||||
case Disposer:
|
||||
d = f
|
||||
case func() error:
|
||||
d = f
|
||||
case func():
|
||||
d = func() error {
|
||||
f()
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("core: OnDispose expects func() error or func(), got %T", fn))
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.disposers = append(c.disposers, d)
|
||||
}
|
||||
|
||||
// Dispose shuts down this Context and all child Contexts, running registered disposers in LIFO order.
|
||||
func (c *Context) Dispose() error {
|
||||
c.mu.Lock()
|
||||
if c.disposed {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
c.disposed = true
|
||||
|
||||
// Copy children and disposers under lock
|
||||
children := make([]*Context, len(c.children))
|
||||
copy(children, c.children)
|
||||
|
||||
disposers := make([]Disposer, len(c.disposers))
|
||||
copy(disposers, c.disposers)
|
||||
c.mu.Unlock()
|
||||
|
||||
var errs []error
|
||||
|
||||
// 1. Dispose all child contexts in reverse order
|
||||
for i := len(children) - 1; i >= 0; i-- {
|
||||
if err := children[i].Dispose(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Run local disposers in LIFO order
|
||||
for i := len(disposers) - 1; i >= 0; i-- {
|
||||
if err := disposers[i](); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Cancel the Go context
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
// 4. Detach from parent
|
||||
if c.parent != nil {
|
||||
c.parent.removeChild(c)
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (c *Context) removeChild(target *Context) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for i, child := range c.children {
|
||||
if child == target {
|
||||
c.children = append(c.children[:i], c.children[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsDisposed returns true if this Context has been disposed.
|
||||
func (c *Context) IsDisposed() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.disposed
|
||||
}
|
||||
|
||||
// RegisterDriver registers a runtime driver engine on this Context hierarchy.
|
||||
func (c *Context) RegisterDriver(d Driver) error {
|
||||
if d == nil {
|
||||
return ErrNilService
|
||||
}
|
||||
|
||||
root := c.Root()
|
||||
root.mu.Lock()
|
||||
root.drivers = append(root.drivers, d)
|
||||
root.mu.Unlock()
|
||||
|
||||
c.OnDispose(func() error {
|
||||
root.mu.Lock()
|
||||
defer root.mu.Unlock()
|
||||
for i, drv := range root.drivers {
|
||||
if drv == d {
|
||||
root.drivers = append(root.drivers[:i], root.drivers[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Drivers returns a copy of all drivers registered on this Context.
|
||||
func (c *Context) Drivers() []Driver {
|
||||
root := c.Root()
|
||||
root.mu.RLock()
|
||||
defer root.mu.RUnlock()
|
||||
|
||||
result := make([]Driver, len(root.drivers))
|
||||
copy(result, root.drivers)
|
||||
return result
|
||||
}
|
||||
|
||||
// Driver looks up a registered driver by its driver type.
|
||||
func (c *Context) Driver(driverType DriverType) (Driver, bool) {
|
||||
root := c.Root()
|
||||
root.mu.RLock()
|
||||
defer root.mu.RUnlock()
|
||||
|
||||
for _, d := range root.drivers {
|
||||
if d.Type() == driverType {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Sample services for testing
|
||||
type SampleService interface {
|
||||
Greet(name string) string
|
||||
}
|
||||
|
||||
type sampleServiceImpl struct {
|
||||
prefix string
|
||||
}
|
||||
|
||||
func (s *sampleServiceImpl) Greet(name string) string {
|
||||
if s.prefix != "" {
|
||||
return s.prefix + " " + name
|
||||
}
|
||||
return "Hello, " + name
|
||||
}
|
||||
|
||||
type LogService interface {
|
||||
Log(msg string)
|
||||
}
|
||||
|
||||
type logServiceImpl struct {
|
||||
logs []string
|
||||
}
|
||||
|
||||
func (l *logServiceImpl) Log(msg string) {
|
||||
l.logs = append(l.logs, msg)
|
||||
}
|
||||
|
||||
type ConfigService interface {
|
||||
Get(key string) string
|
||||
}
|
||||
|
||||
type configServiceImpl struct {
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (c *configServiceImpl) Get(key string) string {
|
||||
return c.data[key]
|
||||
}
|
||||
|
||||
// Sample plugin for testing
|
||||
type samplePlugin struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (p *samplePlugin) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *samplePlugin) Apply(ctx *core.Context) error {
|
||||
core.Provide[SampleService](ctx, &sampleServiceImpl{prefix: "Plugin:"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *samplePlugin) Manifest() core.Manifest {
|
||||
return core.Manifest{
|
||||
Name: p.name,
|
||||
Version: "1.0.0",
|
||||
Description: "Sample plugin",
|
||||
}
|
||||
}
|
||||
|
||||
// Sample driver for testing
|
||||
type mockDriver struct {
|
||||
driverType core.DriverType
|
||||
started bool
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func (m *mockDriver) Type() core.DriverType {
|
||||
return m.driverType
|
||||
}
|
||||
|
||||
func (m *mockDriver) Start(ctx context.Context) error {
|
||||
m.started = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockDriver) Stop(ctx context.Context) error {
|
||||
m.stopped = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestContextProvideAndInject(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
|
||||
// Before providing, Inject should fail
|
||||
_, err := core.Inject[SampleService](ctx)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, core.ErrServiceNotFound))
|
||||
assert.False(t, core.Has[SampleService](ctx))
|
||||
|
||||
// MustInject should panic
|
||||
assert.Panics(t, func() {
|
||||
core.MustInject[SampleService](ctx)
|
||||
})
|
||||
|
||||
// Provide service
|
||||
svcImpl := &sampleServiceImpl{prefix: "Hello,"}
|
||||
core.Provide[SampleService](ctx, svcImpl)
|
||||
|
||||
// Inject should succeed
|
||||
assert.True(t, core.Has[SampleService](ctx))
|
||||
svc, err := core.Inject[SampleService](ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Hello, Wavelet", svc.Greet("Wavelet"))
|
||||
|
||||
// MustInject should succeed
|
||||
mustSvc := core.MustInject[SampleService](ctx)
|
||||
assert.Equal(t, "Hello, Cordis", mustSvc.Greet("Cordis"))
|
||||
}
|
||||
|
||||
func TestContextProvideNilPanics(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
|
||||
assert.Panics(t, func() {
|
||||
core.Provide[SampleService](nil, &sampleServiceImpl{})
|
||||
})
|
||||
|
||||
assert.Panics(t, func() {
|
||||
var nilSvc SampleService
|
||||
core.Provide[SampleService](ctx, nilSvc)
|
||||
})
|
||||
|
||||
assert.Panics(t, func() {
|
||||
var nilImpl *sampleServiceImpl
|
||||
core.Provide[*sampleServiceImpl](ctx, nilImpl)
|
||||
})
|
||||
|
||||
// Inject with nil context
|
||||
var nilCtx *core.Context
|
||||
_, err := core.Inject[SampleService](nilCtx)
|
||||
assert.ErrorIs(t, err, core.ErrNilContext)
|
||||
}
|
||||
|
||||
func TestContextUsing(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
var called bool
|
||||
|
||||
// Using when service not ready should return ErrServiceNotReady
|
||||
err := core.Using(ctx, func(s SampleService) {
|
||||
called = true
|
||||
assert.Equal(t, "Hello, Cordis", s.Greet("Cordis"))
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, core.ErrServiceNotReady))
|
||||
assert.False(t, called)
|
||||
|
||||
// Provide service and try Using again
|
||||
core.Provide[SampleService](ctx, &sampleServiceImpl{})
|
||||
err = core.Using(ctx, func(s SampleService) {
|
||||
called = true
|
||||
assert.Equal(t, "Hello, Cordis", s.Greet("Cordis"))
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestContextUsingMultiple(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
|
||||
// Using2 with missing dependencies
|
||||
var called2 bool
|
||||
err := core.Using2(ctx, func(s SampleService, l LogService) {
|
||||
called2 = true
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.False(t, called2)
|
||||
|
||||
// Provide 1 of 2
|
||||
core.Provide[SampleService](ctx, &sampleServiceImpl{})
|
||||
err = core.Using2(ctx, func(s SampleService, l LogService) {
|
||||
called2 = true
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.False(t, called2)
|
||||
|
||||
// Provide 2 of 2
|
||||
logSvc := &logServiceImpl{}
|
||||
core.Provide[LogService](ctx, logSvc)
|
||||
err = core.Using2(ctx, func(s SampleService, l LogService) {
|
||||
called2 = true
|
||||
l.Log(s.Greet("World"))
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, called2)
|
||||
assert.Equal(t, []string{"Hello, World"}, logSvc.logs)
|
||||
|
||||
// Using3 test - error condition
|
||||
err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) {})
|
||||
assert.Error(t, err)
|
||||
|
||||
// Using3 test - success condition
|
||||
var called3 bool
|
||||
cfgSvc := &configServiceImpl{data: map[string]string{"env": "test"}}
|
||||
core.Provide[ConfigService](ctx, cfgSvc)
|
||||
|
||||
err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) {
|
||||
called3 = true
|
||||
assert.Equal(t, "test", c.Get("env"))
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, called3)
|
||||
}
|
||||
|
||||
// UsingN must keep every dependency failure reachable through the error chain,
|
||||
// not just report that something went wrong.
|
||||
func TestContextUsingMultipleErrorChain(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
|
||||
err := core.Using2(ctx, func(s SampleService, l LogService) {
|
||||
t.Fatal("callback must not run when dependencies are missing")
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, core.ErrServiceNotReady)
|
||||
assert.ErrorIs(t, err, core.ErrServiceNotFound)
|
||||
|
||||
// Only LogService is missing now, so exactly one joined cause must be present.
|
||||
core.Provide[SampleService](ctx, &sampleServiceImpl{})
|
||||
err = core.Using2(ctx, func(s SampleService, l LogService) {
|
||||
t.Fatal("callback must not run when a dependency is missing")
|
||||
})
|
||||
assert.ErrorIs(t, err, core.ErrServiceNotReady)
|
||||
assert.ErrorIs(t, err, core.ErrServiceNotFound)
|
||||
|
||||
err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) {
|
||||
t.Fatal("callback must not run when a dependency is missing")
|
||||
})
|
||||
assert.ErrorIs(t, err, core.ErrServiceNotReady)
|
||||
assert.ErrorIs(t, err, core.ErrServiceNotFound)
|
||||
}
|
||||
|
||||
func TestContextHierarchyAndFork(t *testing.T) {
|
||||
parent := core.NewContext(nil) // nil base context test
|
||||
core.Provide[SampleService](parent, &sampleServiceImpl{prefix: "Parent:"})
|
||||
|
||||
child := parent.ForkWithContext(nil) // nil child context test
|
||||
require.NotNil(t, child)
|
||||
assert.Equal(t, parent, child.Parent())
|
||||
|
||||
// Child can resolve service from parent
|
||||
svc, err := core.Inject[SampleService](child)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Parent: Ryan", svc.Greet("Ryan"))
|
||||
|
||||
// Child provides LogService
|
||||
childLog := &logServiceImpl{}
|
||||
core.ProvideScoped[LogService](child, childLog)
|
||||
|
||||
// Child has LogService, parent does not
|
||||
assert.True(t, core.Has[LogService](child))
|
||||
assert.False(t, core.Has[LogService](parent))
|
||||
|
||||
// Child overrides SampleService locally
|
||||
core.ProvideScoped[SampleService](child, &sampleServiceImpl{prefix: "Child:"})
|
||||
childSvc, err := core.Inject[SampleService](child)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Child: Ryan", childSvc.Greet("Ryan"))
|
||||
|
||||
parentSvc, err := core.Inject[SampleService](parent)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Parent: Ryan", parentSvc.Greet("Ryan"))
|
||||
}
|
||||
|
||||
func TestContextReactiveWhen(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
|
||||
assert.Panics(t, func() {
|
||||
core.When[SampleService](nil, func(s SampleService) {})
|
||||
})
|
||||
|
||||
var whenCalled atomic.Bool
|
||||
var greeted string
|
||||
|
||||
// Register When before service is provided
|
||||
core.When[SampleService](ctx, func(s SampleService) {
|
||||
whenCalled.Store(true)
|
||||
greeted = s.Greet("Reactive")
|
||||
})
|
||||
|
||||
assert.False(t, whenCalled.Load())
|
||||
|
||||
// Now Provide the service - listener should trigger
|
||||
core.Provide[SampleService](ctx, &sampleServiceImpl{})
|
||||
|
||||
assert.True(t, whenCalled.Load())
|
||||
assert.Equal(t, "Hello, Reactive", greeted)
|
||||
|
||||
// Register another When after service is already provided - should trigger immediately
|
||||
var immediateCalled bool
|
||||
core.When[SampleService](ctx, func(s SampleService) {
|
||||
immediateCalled = true
|
||||
})
|
||||
assert.True(t, immediateCalled)
|
||||
}
|
||||
|
||||
func TestWhenObservesProvideFromForkedFiberContext(t *testing.T) {
|
||||
root := core.NewContext(context.Background())
|
||||
adminFiber := root.Fork()
|
||||
lateFiber := root.Fork()
|
||||
|
||||
var got atomic.Bool
|
||||
core.When[SampleService](adminFiber, func(s SampleService) {
|
||||
if s != nil {
|
||||
got.Store(true)
|
||||
}
|
||||
})
|
||||
assert.False(t, got.Load())
|
||||
|
||||
core.Provide[SampleService](lateFiber, &sampleServiceImpl{})
|
||||
assert.True(t, got.Load(), "When on a Fiber child must observe Provide on the root")
|
||||
}
|
||||
|
||||
func TestBindIsWhen(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
var called atomic.Bool
|
||||
core.Bind[SampleService](ctx, func(s SampleService) {
|
||||
called.Store(true)
|
||||
})
|
||||
core.Provide[SampleService](ctx, &sampleServiceImpl{})
|
||||
assert.True(t, called.Load())
|
||||
}
|
||||
|
||||
func TestInjectFromAppContext(t *testing.T) {
|
||||
app := core.NewContext(context.Background())
|
||||
core.Provide[SampleService](app, &sampleServiceImpl{prefix: "Hi:"})
|
||||
|
||||
req := core.WithAppContext(context.Background(), app)
|
||||
svc, err := core.InjectFrom[SampleService](req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Hi: Ada", svc.Greet("Ada"))
|
||||
|
||||
_, err = core.InjectFrom[SampleService](context.Background())
|
||||
assert.ErrorIs(t, err, core.ErrNilContext)
|
||||
}
|
||||
|
||||
func TestContextDisposerLifecycle(t *testing.T) {
|
||||
parent := core.NewContext(context.Background())
|
||||
child := parent.Fork()
|
||||
|
||||
var order []string
|
||||
|
||||
// Test nil disposer
|
||||
parent.OnDispose(nil)
|
||||
|
||||
// Test Disposer type
|
||||
var customDisposer core.Disposer = func() error {
|
||||
order = append(order, "parent-custom")
|
||||
return nil
|
||||
}
|
||||
parent.OnDispose(customDisposer)
|
||||
|
||||
parent.OnDispose(func() error {
|
||||
order = append(order, "parent-1")
|
||||
return nil
|
||||
})
|
||||
parent.OnDispose(func() {
|
||||
order = append(order, "parent-2")
|
||||
})
|
||||
|
||||
child.OnDispose(func() error {
|
||||
order = append(order, "child-1")
|
||||
return errors.New("child-1 error")
|
||||
})
|
||||
child.OnDispose(func() {
|
||||
order = append(order, "child-2")
|
||||
})
|
||||
|
||||
assert.Panics(t, func() {
|
||||
parent.OnDispose("invalid-func")
|
||||
})
|
||||
|
||||
assert.False(t, parent.IsDisposed())
|
||||
assert.False(t, child.IsDisposed())
|
||||
|
||||
// Disposing parent should cascade to children first, and execute disposers in LIFO order
|
||||
err := parent.Dispose()
|
||||
assert.Error(t, err) // child-1 error should be joined
|
||||
assert.Contains(t, err.Error(), "child-1 error")
|
||||
|
||||
assert.True(t, parent.IsDisposed())
|
||||
assert.True(t, child.IsDisposed())
|
||||
|
||||
// Child disposers run in LIFO: child-2, child-1
|
||||
// Parent disposers run in LIFO: parent-2, parent-1, parent-custom
|
||||
expected := []string{"child-2", "child-1", "parent-2", "parent-1", "parent-custom"}
|
||||
assert.Equal(t, expected, order)
|
||||
|
||||
// Disposing again should be idempotent and return nil
|
||||
err = parent.Dispose()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestContextStandardGoContext(t *testing.T) {
|
||||
baseCtx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
|
||||
defer cancel()
|
||||
|
||||
parentCtx := core.NewContext(baseCtx)
|
||||
parentCtx.Set("parent_key", "parent_val")
|
||||
|
||||
childCtx := parentCtx.Fork()
|
||||
|
||||
// Deadline
|
||||
dl, ok := childCtx.Deadline()
|
||||
assert.True(t, ok)
|
||||
assert.False(t, dl.IsZero())
|
||||
|
||||
// Value fallback: child has no key, falls back to parentCtx
|
||||
assert.Equal(t, "parent_val", childCtx.Value("parent_key"))
|
||||
|
||||
// GoContext getter
|
||||
assert.NotNil(t, childCtx.GoContext())
|
||||
|
||||
// Value not found in either
|
||||
assert.Nil(t, childCtx.Value("non_existent_key"))
|
||||
|
||||
// Cancellation propagation
|
||||
select {
|
||||
case <-childCtx.Done():
|
||||
t.Fatal("ctx should not be done yet")
|
||||
default:
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-childCtx.Done():
|
||||
assert.Equal(t, context.Canceled, childCtx.Err())
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("ctx should be cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestValidation(t *testing.T) {
|
||||
mValid := core.Manifest{
|
||||
Name: "auth",
|
||||
Version: "1.0.0",
|
||||
Description: "Auth plugin",
|
||||
}
|
||||
assert.NoError(t, mValid.Validate())
|
||||
|
||||
mInvalid := core.Manifest{
|
||||
Version: "1.0.0",
|
||||
}
|
||||
assert.Error(t, mInvalid.Validate())
|
||||
}
|
||||
|
||||
func TestDriverRegistration(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
|
||||
// Register nil driver returns error
|
||||
assert.ErrorIs(t, ctx.RegisterDriver(nil), core.ErrNilService)
|
||||
|
||||
dHTTP := &mockDriver{driverType: core.DriverTypeHTTP}
|
||||
dWorker := &mockDriver{driverType: core.DriverTypeWorker}
|
||||
|
||||
require.NoError(t, ctx.RegisterDriver(dHTTP))
|
||||
require.NoError(t, ctx.RegisterDriver(dWorker))
|
||||
|
||||
drivers := ctx.Drivers()
|
||||
assert.Len(t, drivers, 2)
|
||||
|
||||
foundHTTP, ok := ctx.Driver(core.DriverTypeHTTP)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, dHTTP, foundHTTP)
|
||||
|
||||
foundWorker, ok := ctx.Driver(core.DriverTypeWorker)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, dWorker, foundWorker)
|
||||
|
||||
_, ok = ctx.Driver(core.DriverTypeScheduler)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestPluginInterfaces(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
var p core.Plugin = &samplePlugin{name: "sample"}
|
||||
assert.Equal(t, "sample", p.Name())
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
svc, err := core.Inject[SampleService](ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Plugin: Ryan", svc.Greet("Ryan"))
|
||||
|
||||
var pwm core.PluginWithManifest = &samplePlugin{name: "sample"}
|
||||
manifest := pwm.Manifest()
|
||||
assert.Equal(t, "sample", manifest.Name)
|
||||
assert.Equal(t, "1.0.0", manifest.Version)
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrently provide, inject, fork, set, and get
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
ctx.Set(fmt.Sprintf("key-%d", idx), idx)
|
||||
_, _ = ctx.Get(fmt.Sprintf("key-%d", idx))
|
||||
|
||||
child := ctx.Fork()
|
||||
child.Set("child_key", idx)
|
||||
}(i)
|
||||
}
|
||||
|
||||
core.Provide[SampleService](ctx, &sampleServiceImpl{})
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
svc, err := core.Inject[SampleService](ctx)
|
||||
if err == nil {
|
||||
_ = svc.Greet("Concurrency")
|
||||
}
|
||||
_ = core.Using(ctx, func(s SampleService) {
|
||||
_ = s.Greet("Safe")
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestContextExtensionPointsAccessors(t *testing.T) {
|
||||
ctx := core.NewContext(nil)
|
||||
assert.NotNil(t, ctx.Events())
|
||||
assert.NotNil(t, ctx.Router())
|
||||
assert.NotNil(t, ctx.Migrations())
|
||||
assert.NotNil(t, ctx.Tasks())
|
||||
assert.NotNil(t, ctx.Task())
|
||||
assert.NotNil(t, ctx.Schedules())
|
||||
assert.NotNil(t, ctx.Schedule())
|
||||
assert.NotNil(t, ctx.Settings())
|
||||
assert.NotNil(t, ctx.Setting())
|
||||
|
||||
child := ctx.Fork()
|
||||
assert.Equal(t, ctx.Events(), child.Events())
|
||||
assert.Equal(t, ctx.Migrations(), child.Migrations())
|
||||
assert.NotNil(t, child.Router())
|
||||
assert.NotNil(t, child.Tasks())
|
||||
assert.NotNil(t, child.Task())
|
||||
assert.NotNil(t, child.Schedules())
|
||||
assert.NotNil(t, child.Schedule())
|
||||
assert.NotNil(t, child.Settings())
|
||||
assert.NotNil(t, child.Setting())
|
||||
}
|
||||
|
||||
func TestContext_ScopedExtpoints_RevertibleEffects(t *testing.T) {
|
||||
root := core.NewContext(context.Background())
|
||||
child := root.Fork()
|
||||
|
||||
// Register route, task, schedule, setting, event, middleware, whitelist on child
|
||||
child.Router().GET("/test-route", func() {})
|
||||
assert.Equal(t, 1, len(root.Router().Routes()))
|
||||
|
||||
child.Router().Use("scoped_middleware")
|
||||
assert.Equal(t, 1, len(root.Router().Middlewares()))
|
||||
|
||||
child.Router().RegisterWhitelist("/api/v1/scoped/*")
|
||||
assert.True(t, root.Router().IsWhitelisted("/api/v1/scoped/test"))
|
||||
|
||||
child.Tasks().Register("test:task", func() {})
|
||||
assert.Equal(t, 1, len(root.Tasks().Tasks()))
|
||||
|
||||
child.Schedules().RegisterCron("@hourly", "test:cron", nil)
|
||||
assert.Equal(t, 1, len(root.Schedules().Schedules()))
|
||||
|
||||
child.Settings().Register(core.SettingSchema{Key: "test.key", Default: "val"})
|
||||
assert.Equal(t, 1, len(root.Settings().Schemas()))
|
||||
|
||||
child.On("test:event", func() {})
|
||||
assert.Equal(t, 1, root.Events().Listeners("test:event"))
|
||||
|
||||
// Dispose child
|
||||
err := child.Dispose()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// All child effects should be cleanly revoked in LIFO order
|
||||
assert.Equal(t, 0, len(root.Router().Routes()))
|
||||
assert.Equal(t, 0, len(root.Router().Middlewares()))
|
||||
assert.False(t, root.Router().IsWhitelisted("/api/v1/scoped/test"))
|
||||
assert.Equal(t, 0, len(root.Tasks().Tasks()))
|
||||
assert.Equal(t, 0, len(root.Schedules().Schedules()))
|
||||
assert.Equal(t, 0, len(root.Settings().Schemas()))
|
||||
assert.Equal(t, 0, root.Events().Listeners("test:event"))
|
||||
}
|
||||
|
||||
func TestContainer_InterfaceResolutionCache(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
svc := &sampleServiceImpl{prefix: "Cached:"}
|
||||
|
||||
core.Provide[SampleService](ctx, svc)
|
||||
|
||||
// 1. Initial resolution populates interfaceCache
|
||||
res1, err := core.Inject[SampleService](ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Cached: Alice", res1.Greet("Alice"))
|
||||
|
||||
// 2. Subsequent resolutions hit interfaceCache
|
||||
res2, err := core.Inject[SampleService](ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Same(t, res1, res2)
|
||||
|
||||
// 3. Concurrent lookups
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r, e := core.Inject[SampleService](ctx)
|
||||
assert.NoError(t, e)
|
||||
assert.Equal(t, "Cached: Bob", r.Greet("Bob"))
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 4. Overriding/providing another service invalidates cache
|
||||
svc2 := &sampleServiceImpl{prefix: "Updated:"}
|
||||
core.Provide[SampleService](ctx, svc2)
|
||||
|
||||
res3, err := core.Inject[SampleService](ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updated: Alice", res3.Greet("Alice"))
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UserDTO represents a unified user data transfer object across plugins.
|
||||
type UserDTO struct {
|
||||
ID uint64 `json:"id,string"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
NeedChangePassword bool `json:"need_change_password,omitempty"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Gender string `json:"gender,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
LastLoginAt time.Time `json:"last_login_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// OAuthUserInfoDTO contains user identity claims obtained from an OAuth provider.
|
||||
type OAuthUserInfoDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
Sub string `json:"sub"`
|
||||
Username string `json:"username"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
// AuthSourceDTO represents an OAuth / OIDC authentication source.
|
||||
type AuthSourceDTO struct {
|
||||
ID uint64 `json:"id,string"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
OpenIDDiscoveryURL string `json:"openid_discovery_url"`
|
||||
Scopes string `json:"scopes"`
|
||||
IconURL string `json:"icon_url"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AuthSourceViewDTO is a sanitized view of an AuthSource for admin display.
|
||||
type AuthSourceViewDTO struct {
|
||||
ID uint64 `json:"id,string"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IconURL string `json:"icon_url"`
|
||||
ClientSecretConfigured bool `json:"client_secret_configured"`
|
||||
}
|
||||
|
||||
// OAuthProvider defines the pluggable OAuth provider contract.
|
||||
type OAuthProvider interface {
|
||||
Name() string
|
||||
GetAuthURL(state string) string
|
||||
ExchangeCode(ctx context.Context, code string) (*OAuthUserInfoDTO, error)
|
||||
}
|
||||
|
||||
// AuthService defines the contract for authentication, session verification, and token management.
|
||||
type AuthService interface {
|
||||
// RequireAuthMiddleware returns a middleware handler (compatible with gin.HandlerFunc or standard middleware).
|
||||
RequireAuthMiddleware() any
|
||||
|
||||
// RequireAdminMiddleware returns an admin authorization middleware.
|
||||
RequireAdminMiddleware() any
|
||||
|
||||
// GetCurrentUser retrieves the authenticated UserDTO from context.
|
||||
GetCurrentUser(ctx context.Context) (*UserDTO, error)
|
||||
|
||||
// GetCurrentUserID retrieves the authenticated user ID from session/context.
|
||||
GetCurrentUserID(ctx context.Context) (uint64, error)
|
||||
|
||||
// VerifyToken validates an access token and returns the associated user DTO.
|
||||
VerifyToken(ctx context.Context, token string) (*UserDTO, error)
|
||||
|
||||
// CreateSession establishes an authenticated session for the given user ID.
|
||||
CreateSession(ctx context.Context, userID uint64, extras map[string]any) (string, error)
|
||||
|
||||
// RevokeToken invalidates a specific access token by its hash.
|
||||
RevokeToken(ctx context.Context, tokenHash string) error
|
||||
|
||||
// RevokeUserSessions revokes all active sessions and cached tokens for a user.
|
||||
RevokeUserSessions(ctx context.Context, userID uint64) error
|
||||
|
||||
// InvalidateCachedUser invalidates cached user profile data.
|
||||
InvalidateCachedUser(ctx context.Context, userID uint64)
|
||||
|
||||
// InvalidateCachedToken invalidates cached access token data.
|
||||
InvalidateCachedToken(ctx context.Context, tokenHash string)
|
||||
|
||||
// ListAuthSources lists all configured authentication sources.
|
||||
ListAuthSources(ctx context.Context) ([]AuthSourceViewDTO, error)
|
||||
|
||||
// CreateAuthSource creates a new authentication source.
|
||||
CreateAuthSource(ctx context.Context, source AuthSourceDTO) (*AuthSourceDTO, error)
|
||||
|
||||
// UpdateAuthSource updates an authentication source.
|
||||
UpdateAuthSource(ctx context.Context, id uint64, source AuthSourceDTO) (*AuthSourceDTO, error)
|
||||
|
||||
// DeleteAuthSource removes an authentication source.
|
||||
DeleteAuthSource(ctx context.Context, id uint64) error
|
||||
|
||||
// ToggleAuthSource toggles the active state of an authentication source.
|
||||
ToggleAuthSource(ctx context.Context, id uint64) (*AuthSourceDTO, error)
|
||||
|
||||
// DisallowTokenAuthMiddleware returns a middleware that rejects requests authenticated via access token.
|
||||
DisallowTokenAuthMiddleware() any
|
||||
}
|
||||
|
||||
// AuthRegistry allows downstream and domain plugins to register custom authentication providers.
|
||||
type AuthRegistry interface {
|
||||
RegisterOAuthProvider(name string, provider OAuthProvider)
|
||||
GetOAuthProvider(name string) (OAuthProvider, bool)
|
||||
ListOAuthProviders() []string
|
||||
}
|
||||
|
||||
// Auth context keys — stored in Gin context by auth middleware, consumed by domain plugins.
|
||||
const (
|
||||
AuthUserIDKey = "user_id"
|
||||
AuthUserNameKey = "username"
|
||||
AuthUserObjKey = "user_obj"
|
||||
AuthTokenAuthKey = "token_auth" // marks if request uses access token auth
|
||||
AuthTokenAdminKey = "token_admin" // whether the access token has admin privileges
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrCacheMiss is returned when an item is not found in the cache.
|
||||
var ErrCacheMiss = errors.New("contracts/cache: key not found")
|
||||
|
||||
// CacheService defines the contract for multi-layer cache operations (RAM L1 + Redis L2 + Pub/Sub invalidation).
|
||||
type CacheService interface {
|
||||
// Get retrieves an item from cache into target. Returns ErrCacheMiss if not found.
|
||||
Get(ctx context.Context, key string, target any) error
|
||||
|
||||
// Set stores an item into cache with a specified time-to-live duration.
|
||||
Set(ctx context.Context, key string, value any, ttl time.Duration) error
|
||||
|
||||
// Delete evicts a key from local and remote cache tiers and broadcasts invalidation.
|
||||
Delete(ctx context.Context, key string) error
|
||||
|
||||
// GetOrSet retrieves an item from cache, or calls loader to populate and return if missing.
|
||||
GetOrSet(ctx context.Context, key string, target any, ttl time.Duration, loader func() (any, error)) error
|
||||
|
||||
// Invalidate is a semantic alias for Delete.
|
||||
Invalidate(ctx context.Context, key string) error
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package contracts
|
||||
|
||||
// CaptchaService defines the contract for CAPTCHA challenge issuance,
|
||||
// redemption, and scoped verification middleware.
|
||||
type CaptchaService interface {
|
||||
VerifyMiddleware(scope string) any
|
||||
ChallengeHandler() any
|
||||
RedeemHandler() any
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SystemConfigDTO represents a system configuration key-value entry.
|
||||
type SystemConfigDTO struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
Visibility int `json:"visibility"`
|
||||
Description string `json:"description"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SystemConfigService defines the unified contract for querying and mutating system configurations.
|
||||
type SystemConfigService interface {
|
||||
GetByKey(ctx context.Context, key string) (SystemConfigDTO, error)
|
||||
ListByKeys(ctx context.Context, keys []string) (map[string]SystemConfigDTO, error)
|
||||
ListVisible(ctx context.Context) ([]SystemConfigDTO, error)
|
||||
ListByType(ctx context.Context, configType string) ([]SystemConfigDTO, error)
|
||||
GetIntByKey(ctx context.Context, key string) (int, error)
|
||||
GetBoolByKey(ctx context.Context, key string) (bool, error)
|
||||
SaveOrUpdate(ctx context.Context, key, value string) error
|
||||
InvalidateCache(ctx context.Context, key string) error
|
||||
InvalidateAllCaches(ctx context.Context) error
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package contracts
|
||||
|
||||
import "context"
|
||||
|
||||
// PublicConfigProvider supplies GET /api/v1/config/public.
|
||||
// The owner of w_system_configs (admin) must provide this. The payload is a
|
||||
// flat key/value map of visibility=1 rows; the frontend reads keys such as
|
||||
// cap_login_enabled directly off data.
|
||||
type PublicConfigProvider interface {
|
||||
PublicConfig(ctx context.Context) (map[string]string, error)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DBService defines the standard contract for relational database access and multi-datasource routing.
|
||||
type DBService interface {
|
||||
// GORM returns the underlying GORM database instance.
|
||||
GORM() *gorm.DB
|
||||
|
||||
// DB returns the GORM database instance bound to the given context.
|
||||
DB(ctx context.Context) *gorm.DB
|
||||
|
||||
// Named returns a named database connection if multiple data sources or replicas are configured.
|
||||
Named(name string) *gorm.DB
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
// ======================================================================
|
||||
// Domain Event Topic Constants
|
||||
// ======================================================================
|
||||
//
|
||||
// All cross-plugin domain event topics MUST be declared here so that
|
||||
// producers and consumers share the same string values without importing
|
||||
// each other's implementation packages.
|
||||
// ======================================================================
|
||||
|
||||
// --- Auth & User Events ---
|
||||
const (
|
||||
// EventTopicAdminLoggedIn fires when an admin user logs in.
|
||||
EventTopicAdminLoggedIn = "admin:logged_in"
|
||||
|
||||
// EventTopicUserCreated fires when a new user account is created.
|
||||
EventTopicUserCreated = "user:created"
|
||||
|
||||
// EventTopicUserUpdated fires when a user profile is updated.
|
||||
EventTopicUserUpdated = "user:updated"
|
||||
|
||||
// EventTopicUserDeleted fires when a user account is deleted.
|
||||
EventTopicUserDeleted = "user:deleted"
|
||||
|
||||
// EventTopicUserStatusChanged fires when a user account active status changes.
|
||||
EventTopicUserStatusChanged = "user:status_changed"
|
||||
|
||||
// EventTopicTokenRevoked fires when an access token is revoked.
|
||||
// #nosec G101
|
||||
EventTopicTokenRevoked = "auth:token_revoked"
|
||||
)
|
||||
|
||||
// --- Admin & System Events ---
|
||||
const (
|
||||
// EventTopicConfigChanged fires when a system configuration value changes.
|
||||
EventTopicConfigChanged = "admin:config_changed"
|
||||
|
||||
// EventTopicSystemCleanup fires when a periodic system cleanup completes.
|
||||
EventTopicSystemCleanup = "admin:system_cleanup"
|
||||
)
|
||||
|
||||
// --- Task Events ---
|
||||
const (
|
||||
// EventTopicTaskCompleted fires when an asynchronous background task execution finishes.
|
||||
EventTopicTaskCompleted = "task:completed"
|
||||
)
|
||||
|
||||
// TaskCompletedEvent carries task execution outcome details.
|
||||
type TaskCompletedEvent struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskName string `json:"task_name"`
|
||||
TaskType string `json:"task_type"`
|
||||
Status string `json:"status"`
|
||||
Duration int64 `json:"duration"`
|
||||
ErrorMsg string `json:"error_msg,omitempty"`
|
||||
ResultMsg string `json:"result_msg,omitempty"`
|
||||
Payload string `json:"payload,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// --- Upload / Storage Events ---
|
||||
const (
|
||||
// EventTopicUploadCreated fires when a new file upload is recorded.
|
||||
EventTopicUploadCreated = "upload:created"
|
||||
|
||||
// EventTopicUploadDeleted fires when a file upload is removed.
|
||||
EventTopicUploadDeleted = "upload:deleted"
|
||||
|
||||
// EventTopicIngestComplete fires when a programmatic file ingest finishes.
|
||||
EventTopicIngestComplete = "upload:ingest_complete"
|
||||
)
|
||||
|
||||
// --- Message Gateway Events ---
|
||||
const (
|
||||
// EventTopicNotificationSent fires when a push notification is dispatched.
|
||||
EventTopicNotificationSent = "message:notification_sent"
|
||||
|
||||
// EventTopicChannelBound fires when a user binds a messaging channel.
|
||||
EventTopicChannelBound = "message:channel_bound"
|
||||
|
||||
// EventTopicChannelUnbound fires when a user unbinds a messaging channel.
|
||||
EventTopicChannelUnbound = "message:channel_unbound"
|
||||
)
|
||||
|
||||
// --- Risk Control Events ---
|
||||
const (
|
||||
// EventTopicAccessLogRecorded fires when a user access log entry is recorded.
|
||||
EventTopicAccessLogRecorded = "risk:access_log_recorded"
|
||||
)
|
||||
|
||||
// ======================================================================
|
||||
// Domain Event Payload DTOs
|
||||
// ======================================================================
|
||||
|
||||
// AdminLoggedIn 管理员登录领域事件载荷
|
||||
type AdminLoggedIn struct {
|
||||
User *UserDTO `json:"user"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
// UserCreatedEvent fires when a new user account is created.
|
||||
type UserCreatedEvent struct {
|
||||
User *UserDTO `json:"user"`
|
||||
Password string `json:"-"`
|
||||
}
|
||||
|
||||
// ConfigChangedEvent fires when a system configuration value changes.
|
||||
type ConfigChangedEvent struct {
|
||||
Key string `json:"key"`
|
||||
OldVal any `json:"old_val,omitempty"`
|
||||
NewVal any `json:"new_val,omitempty"`
|
||||
}
|
||||
|
||||
// UploadCreatedEvent fires when a new file upload is recorded.
|
||||
type UploadCreatedEvent struct {
|
||||
UploadID uint64 `json:"upload_id,string"`
|
||||
UserID uint64 `json:"user_id,string"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
}
|
||||
|
||||
// NotificationSentEvent fires when a push notification is dispatched.
|
||||
type NotificationSentEvent struct {
|
||||
UserID uint64 `json:"user_id,string"`
|
||||
Channel string `json:"channel"`
|
||||
Title string `json:"title"`
|
||||
Success bool `json:"success"`
|
||||
ErrorInfo string `json:"error_info,omitempty"`
|
||||
}
|
||||
|
||||
// UserStatusChangedEvent fires when a user status is enabled/disabled.
|
||||
type UserStatusChangedEvent struct {
|
||||
UserID uint64 `json:"user_id,string"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// TokenRevokedEvent fires when an access token is revoked.
|
||||
type TokenRevokedEvent struct {
|
||||
UserID uint64 `json:"user_id,string"`
|
||||
TokenHash string `json:"token_hash"`
|
||||
}
|
||||
|
||||
// UserDeletedEvent fires when a user account is deleted.
|
||||
type UserDeletedEvent struct {
|
||||
CurrentUserID uint64 `json:"current_user_id,string"`
|
||||
TargetUserID uint64 `json:"target_user_id,string"`
|
||||
}
|
||||
|
||||
// SystemCleanupEvent fires when a periodic system cleanup is triggered.
|
||||
type SystemCleanupEvent struct {
|
||||
TriggeredAt string `json:"triggered_at"`
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Rate specifies a rate limit of Limit events permitted within a Period.
|
||||
type Rate struct {
|
||||
Limit int `json:"limit"`
|
||||
Period time.Duration `json:"period"`
|
||||
}
|
||||
|
||||
// RateLimitResult holds the outcome of a rate limit check.
|
||||
type RateLimitResult struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Remaining int `json:"remaining"`
|
||||
ResetAfter time.Duration `json:"reset_after"`
|
||||
RetryAfter time.Duration `json:"retry_after"`
|
||||
}
|
||||
|
||||
// LimiterService defines the rate limiting service contract for cross-plugin communication.
|
||||
type LimiterService interface {
|
||||
// Allow checks whether 1 event for the given key is permitted under the specified rate.
|
||||
Allow(ctx context.Context, key string, rate Rate) (*RateLimitResult, error)
|
||||
|
||||
// AllowN checks whether n events for the given key are permitted under the specified rate.
|
||||
AllowN(ctx context.Context, key string, rate Rate, n int) (*RateLimitResult, error)
|
||||
|
||||
// Reset clears the rate limit state for the given key.
|
||||
Reset(ctx context.Context, key string) error
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// LoggerService defines the contract for structured logging with trace ID and context correlation.
|
||||
type LoggerService interface {
|
||||
// Debug logs a debug message with optional key-value structured fields.
|
||||
Debug(ctx context.Context, msg string, keysAndValues ...any)
|
||||
|
||||
// Info logs an informational message with optional key-value structured fields.
|
||||
Info(ctx context.Context, msg string, keysAndValues ...any)
|
||||
|
||||
// Warn logs a warning message with optional key-value structured fields.
|
||||
Warn(ctx context.Context, msg string, keysAndValues ...any)
|
||||
|
||||
// Error logs an error message with optional key-value structured fields.
|
||||
Error(ctx context.Context, msg string, keysAndValues ...any)
|
||||
|
||||
// Debugf logs a formatted debug message.
|
||||
Debugf(ctx context.Context, format string, args ...any)
|
||||
|
||||
// Infof logs a formatted informational message.
|
||||
Infof(ctx context.Context, format string, args ...any)
|
||||
|
||||
// Warnf logs a formatted warning message.
|
||||
Warnf(ctx context.Context, format string, args ...any)
|
||||
|
||||
// Errorf logs a formatted error message.
|
||||
Errorf(ctx context.Context, format string, args ...any)
|
||||
|
||||
// With returns a child logger enriched with additional key-value attributes.
|
||||
With(keysAndValues ...any) LoggerService
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
import "context"
|
||||
|
||||
// PushNotificationTemplate defines notification message template payload.
|
||||
type PushNotificationTemplate struct {
|
||||
Title string
|
||||
Content string
|
||||
Level string
|
||||
Ext map[string]any
|
||||
}
|
||||
|
||||
// PushEventMeta defines metadata for a system push event.
|
||||
type PushEventMeta struct {
|
||||
Key string
|
||||
Name string
|
||||
Description string
|
||||
DefaultTemplate PushNotificationTemplate
|
||||
}
|
||||
|
||||
// PushRegistry defines the interface for registering built-in events.
|
||||
type PushRegistry interface {
|
||||
RegisterBuiltInEvent(meta PushEventMeta)
|
||||
SyncEvents(ctx context.Context) error
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AccessLogFilterDTO defines filter criteria for querying user access logs.
|
||||
type AccessLogFilterDTO struct {
|
||||
UserIDs []uint64
|
||||
Path string
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
}
|
||||
|
||||
// AccessLogDTO represents a single access log entry.
|
||||
type AccessLogDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Status int32 `json:"status"`
|
||||
Latency int64 `json:"latency"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AccessLogDailyStatsDTO represents aggregate access statistics for a single day.
|
||||
type AccessLogDailyStatsDTO struct {
|
||||
Date string `json:"date"`
|
||||
PV uint64 `json:"pv"`
|
||||
UV uint64 `json:"uv"`
|
||||
IPCount uint64 `json:"ip_count"`
|
||||
ErrorCount uint64 `json:"error_count"`
|
||||
AvgLatencyMs int64 `json:"avg_latency_ms"`
|
||||
SlowReqCount uint64 `json:"slow_req_count"`
|
||||
MaxLatencyMs int64 `json:"max_latency_ms"`
|
||||
P95LatencyMs int64 `json:"p95_latency_ms"`
|
||||
P99LatencyMs int64 `json:"p99_latency_ms"`
|
||||
}
|
||||
|
||||
// RiskControlService defines the contract for accessing security risk control and audit logstore.
|
||||
type RiskControlService interface {
|
||||
// QueryAccessLogs retrieves paginated access logs matching the filter.
|
||||
QueryAccessLogs(ctx context.Context, filter AccessLogFilterDTO, page, pageSize int) ([]AccessLogDTO, uint64, error)
|
||||
|
||||
// QueryAccessLogStats returns aggregate daily statistics for the last N days.
|
||||
QueryAccessLogStats(ctx context.Context, days int) ([]AccessLogDailyStatsDTO, error)
|
||||
|
||||
// ActiveLogEngine returns the current active logstore engine name.
|
||||
ActiveLogEngine(ctx context.Context) string
|
||||
|
||||
// IsLogEngineMigrating reports whether a log engine migration is in progress.
|
||||
IsLogEngineMigrating(ctx context.Context) bool
|
||||
|
||||
// Drain flushes pending in-flight log buffers.
|
||||
Drain(ctx context.Context) error
|
||||
|
||||
// SwitchLogEngine migrates and switches the active log storage engine.
|
||||
SwitchLogEngine(ctx context.Context, targetEngine string) error
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// StorageObject represents a retrieved file object from the storage backend.
|
||||
type StorageObject struct {
|
||||
Key string
|
||||
CachePath string
|
||||
Body io.ReadCloser
|
||||
ContentLength int64
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// StoragePutResult describes the output of a successful Put operation.
|
||||
type StoragePutResult struct {
|
||||
Key string
|
||||
Bucket string
|
||||
}
|
||||
|
||||
// IngestOptions configures programmatic ingest of files into the platform storage.
|
||||
type IngestOptions struct {
|
||||
UserID uint64
|
||||
Type string
|
||||
FileName string
|
||||
MimeType string
|
||||
Extension string
|
||||
Size int64
|
||||
Policy int
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
// IngestResult reports the outcome of a programmatic file ingest operation.
|
||||
type IngestResult struct {
|
||||
ID uint64
|
||||
Key string
|
||||
URL string
|
||||
Created bool
|
||||
Stored bool
|
||||
Resolved bool
|
||||
}
|
||||
|
||||
// StorageDriver identifies a supported storage backend.
|
||||
type StorageDriver string
|
||||
|
||||
// Storage drivers supported by the platform. Values persist in storage configs.
|
||||
const (
|
||||
StorageDriverLocal StorageDriver = "local"
|
||||
StorageDriverS3 StorageDriver = "s3"
|
||||
StorageDriverR2 StorageDriver = "r2"
|
||||
StorageDriverMinIO StorageDriver = "minio"
|
||||
StorageDriverOSS StorageDriver = "oss"
|
||||
StorageDriverWebDAV StorageDriver = "webdav"
|
||||
)
|
||||
|
||||
// LocalStorageConfigDTO configures local filesystem storage.
|
||||
type LocalStorageConfigDTO struct {
|
||||
Root string `json:"root"`
|
||||
}
|
||||
|
||||
// ObjectStorageConfigDTO configures S3-compatible or OSS object storage.
|
||||
type ObjectStorageConfigDTO struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKeyID string `json:"access_key_id"`
|
||||
SecretAccessKey string `json:"secret_access_key"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
PathStyle bool `json:"path_style"`
|
||||
KeyPrefix string `json:"key_prefix"`
|
||||
CDNURL string `json:"cdn_url"`
|
||||
}
|
||||
|
||||
// WebDAVStorageConfigDTO configures WebDAV storage.
|
||||
type WebDAVStorageConfigDTO struct {
|
||||
URL string `json:"url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Root string `json:"root"`
|
||||
}
|
||||
|
||||
// StorageConfigDTO encapsulates full storage configuration across all backends.
|
||||
type StorageConfigDTO struct {
|
||||
Driver StorageDriver `json:"driver"`
|
||||
Local LocalStorageConfigDTO `json:"local"`
|
||||
S3 ObjectStorageConfigDTO `json:"s3"`
|
||||
R2 ObjectStorageConfigDTO `json:"r2"`
|
||||
MinIO ObjectStorageConfigDTO `json:"minio"`
|
||||
OSS ObjectStorageConfigDTO `json:"oss"`
|
||||
WebDAV WebDAVStorageConfigDTO `json:"webdav"`
|
||||
}
|
||||
|
||||
// StorageService defines the contract for unified object storage and managed file ingestion.
|
||||
type StorageService interface {
|
||||
// Put writes an object to storage.
|
||||
Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (StoragePutResult, error)
|
||||
|
||||
// Get retrieves an object from storage.
|
||||
Get(ctx context.Context, key string) (*StorageObject, error)
|
||||
|
||||
// Delete removes an object from storage.
|
||||
Delete(ctx context.Context, key string) error
|
||||
|
||||
// Ingest performs managed file ingestion into the platform storage domain with deduplication and metadata tracking.
|
||||
Ingest(ctx context.Context, reader io.Reader, opts IngestOptions) (*IngestResult, error)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TaskParamDTO describes a parameter accepted by a background task.
|
||||
type TaskParamDTO struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
// TaskMetaDTO describes the metadata and configuration of a registered background task.
|
||||
type TaskMetaDTO struct {
|
||||
Type string `json:"type"`
|
||||
AsynqTask string `json:"asynq_task"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category,omitempty"`
|
||||
SupportsTime bool `json:"supports_time"`
|
||||
Params []TaskParamDTO `json:"params,omitempty"`
|
||||
MaxRetry int `json:"max_retry"`
|
||||
Timeout time.Duration `json:"timeout,omitempty"`
|
||||
Queue string `json:"queue"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Schedule string `json:"schedule,omitempty"`
|
||||
}
|
||||
|
||||
// TaskResultDTO represents the outcome of a background task execution.
|
||||
type TaskResultDTO struct {
|
||||
Message string `json:"message"`
|
||||
Detail any `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// TaskHandler is the preferred background task handler. Drivers invoke Execute
|
||||
// and persist Message/Detail onto the execution record.
|
||||
type TaskHandler interface {
|
||||
Execute(ctx context.Context, payload []byte) (*TaskResultDTO, error)
|
||||
}
|
||||
|
||||
// TaskExecutionDTO represents a single task execution record.
|
||||
type TaskExecutionDTO struct {
|
||||
ID uint64 `json:"id,string"`
|
||||
TaskID string `json:"task_id"`
|
||||
TaskType string `json:"task_type"`
|
||||
TaskName string `json:"task_name"`
|
||||
Status string `json:"status"`
|
||||
Retryable bool `json:"retryable"`
|
||||
MaxRetry int `json:"max_retry"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
Log string `json:"log"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Result string `json:"result"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
Duration int64 `json:"duration"`
|
||||
Payload string `json:"payload"`
|
||||
TriggeredBy string `json:"triggered_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Canonical triggered_by values persisted on task executions and shown in admin UI.
|
||||
const (
|
||||
TaskTriggerSystem = "system"
|
||||
TaskTriggerManual = "manual"
|
||||
TaskTriggerRetry = "retry"
|
||||
TaskTriggerSchedule = "schedule"
|
||||
)
|
||||
|
||||
// TaskService defines the unified contract for dispatching and tracking background tasks.
|
||||
type TaskService interface {
|
||||
Dispatch(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error)
|
||||
Retry(ctx context.Context, id uint64) (string, error)
|
||||
ListTasks() []TaskMetaDTO
|
||||
GetTaskMeta(taskType string) (TaskMetaDTO, bool)
|
||||
ValidatePayload(taskType string, payload []byte) ([]byte, error)
|
||||
ReloadScheduler() error
|
||||
AppendLog(ctx context.Context, format string, args ...any)
|
||||
ListExecutions(ctx context.Context, taskType, status string, page, pageSize int) ([]TaskExecutionDTO, int64, error)
|
||||
GetExecution(ctx context.Context, id uint64) (*TaskExecutionDTO, error)
|
||||
GetExecutionByTaskID(ctx context.Context, taskID string) (*TaskExecutionDTO, error)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UploadMetadataDTO represents upload metadata JSON.
|
||||
type UploadMetadataDTO struct {
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
OriginalMime string `json:"original_mime,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
ClientIP string `json:"client_ip,omitempty"`
|
||||
Bucket string `json:"bucket,omitempty"`
|
||||
Extra map[string]any `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface for database serialization.
|
||||
func (m UploadMetadataDTO) Value() (driver.Value, error) {
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface for database deserialization.
|
||||
func (m *UploadMetadataDTO) Scan(value any) error {
|
||||
if value == nil {
|
||||
*m = UploadMetadataDTO{}
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
return json.Unmarshal(v, m)
|
||||
case string:
|
||||
return json.Unmarshal([]byte(v), m)
|
||||
default:
|
||||
return fmt.Errorf("cannot scan type %T into UploadMetadataDTO", value)
|
||||
}
|
||||
}
|
||||
|
||||
// UploadDTO represents an uploaded file record.
|
||||
type UploadDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
FileName string `json:"file_name"`
|
||||
FilePath string `json:"file_path"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int64 `json:"size"`
|
||||
Hash string `json:"hash"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"`
|
||||
Metadata UploadMetadataDTO `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// OpenedUploadDTO encapsulates the retrieved object stream and its metadata.
|
||||
type OpenedUploadDTO struct {
|
||||
Upload UploadDTO
|
||||
Body io.ReadCloser
|
||||
ContentType string
|
||||
ContentLength int64
|
||||
}
|
||||
|
||||
// UploadService defines the unified contract for managed file uploads and media entities.
|
||||
type UploadService interface {
|
||||
GetByID(ctx context.Context, id uint64) (*UploadDTO, error)
|
||||
OpenStoredUpload(ctx context.Context, id uint64) (*OpenedUploadDTO, error)
|
||||
Remove(ctx context.Context, id uint64) error
|
||||
RemoveOwned(ctx context.Context, id uint64, userID uint64) error
|
||||
FindByHash(ctx context.Context, hash string, size int64) (*UploadDTO, error)
|
||||
RebuildStats(ctx context.Context) error
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// CreateUserRequest contains fields to register or create a new user.
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
// UpdateUserProfileRequest contains fields for updating a user's profile.
|
||||
type UpdateUserProfileRequest struct {
|
||||
Nickname *string `json:"nickname,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
Bio *string `json:"bio,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Gender *string `json:"gender,omitempty"`
|
||||
Website *string `json:"website,omitempty"`
|
||||
Location *string `json:"location,omitempty"`
|
||||
}
|
||||
|
||||
// AdminListUsersFilter contains query parameters for filtering users in admin panel.
|
||||
type AdminListUsersFilter struct {
|
||||
Page int
|
||||
PageSize int
|
||||
UserID *uint64
|
||||
Username string
|
||||
Email string
|
||||
}
|
||||
|
||||
// AdminCreateUserRequest contains fields for admin to create a user.
|
||||
type AdminCreateUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
// AdminUpdateUserRequest contains fields for admin to update a user.
|
||||
type AdminUpdateUserRequest struct {
|
||||
ID uint64 `json:"id,string"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// UserService defines the contract for user account management and profile queries.
|
||||
type UserService interface {
|
||||
// GetUserByID retrieves a user by ID.
|
||||
GetUserByID(ctx context.Context, id uint64) (*UserDTO, error)
|
||||
|
||||
// GetUsersByIDs retrieves several users in one round-trip. An empty ids
|
||||
// slice yields no results and touches no storage.
|
||||
GetUsersByIDs(ctx context.Context, ids []uint64) ([]*UserDTO, error)
|
||||
|
||||
// GetUserByUsername retrieves a user by username.
|
||||
GetUserByUsername(ctx context.Context, username string) (*UserDTO, error)
|
||||
|
||||
// GetUserByEmail retrieves a user by email.
|
||||
GetUserByEmail(ctx context.Context, email string) (*UserDTO, error)
|
||||
|
||||
// CreateUser registers or creates a new user account.
|
||||
CreateUser(ctx context.Context, req CreateUserRequest) (*UserDTO, error)
|
||||
|
||||
// UpdateProfile updates the profile of the specified user.
|
||||
UpdateProfile(ctx context.Context, id uint64, req UpdateUserProfileRequest) (*UserDTO, error)
|
||||
|
||||
// UpdatePassword updates the password for the specified user after verifying the old password.
|
||||
UpdatePassword(ctx context.Context, id uint64, oldPassword, newPassword string) error
|
||||
|
||||
// VerifyPassword verifies if the given password matches the user's password.
|
||||
VerifyPassword(ctx context.Context, id uint64, password string) bool
|
||||
|
||||
// UpdateLastLogin updates the user's last login timestamp.
|
||||
UpdateLastLogin(ctx context.Context, id uint64, ip string) error
|
||||
|
||||
// ListUsers returns a paginated list of users with optional keyword search.
|
||||
ListUsers(ctx context.Context, page, pageSize int, keyword string) ([]*UserDTO, int64, error)
|
||||
|
||||
// SetUserActive sets the active/banned status for a user.
|
||||
SetUserActive(ctx context.Context, id uint64, active bool) error
|
||||
|
||||
// SetUserAdmin sets the admin role status for a user.
|
||||
SetUserAdmin(ctx context.Context, id uint64, admin bool) error
|
||||
|
||||
// VerifyAccessToken verifies an access token hash and returns the user DTO and isAdmin flag.
|
||||
VerifyAccessToken(ctx context.Context, tokenHash string) (*UserDTO, bool, error)
|
||||
|
||||
// DeleteUser removes a user and related access tokens.
|
||||
DeleteUser(ctx context.Context, id uint64) error
|
||||
|
||||
// CountUsers returns total user count.
|
||||
CountUsers(ctx context.Context) (int64, error)
|
||||
|
||||
// CountActiveUsers returns active user count.
|
||||
CountActiveUsers(ctx context.Context) (int64, error)
|
||||
|
||||
// GetFirstAdminUser returns the earliest admin user.
|
||||
GetFirstAdminUser(ctx context.Context) (*UserDTO, error)
|
||||
|
||||
// UniqueUsername generates a unique username candidate based on base.
|
||||
UniqueUsername(ctx context.Context, base string) (string, error)
|
||||
|
||||
// AdminListUsers returns a filtered paginated list of users for admin management.
|
||||
AdminListUsers(ctx context.Context, filter AdminListUsersFilter) (int64, []*UserDTO, error)
|
||||
|
||||
// AdminGetUser retrieves complete user details by ID for admin management.
|
||||
AdminGetUser(ctx context.Context, id uint64) (*UserDTO, error)
|
||||
|
||||
// AdminCreateUser creates a user with admin specified options.
|
||||
AdminCreateUser(ctx context.Context, req AdminCreateUserRequest) (*UserDTO, error)
|
||||
|
||||
// AdminUpdateUser updates user details, email, nickname, admin role, and optional password.
|
||||
AdminUpdateUser(ctx context.Context, currentUserID uint64, req AdminUpdateUserRequest) error
|
||||
|
||||
// AdminUpdateUserStatus updates a user's active status (with admin protection).
|
||||
AdminUpdateUserStatus(ctx context.Context, id uint64, active bool) error
|
||||
|
||||
// AdminDeleteUser deletes a user (with self and admin protection, cascading tokens and accounts).
|
||||
AdminDeleteUser(ctx context.Context, currentUserID, targetID uint64) error
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
const maxHandlerParams = 2
|
||||
|
||||
var (
|
||||
ctxInterfaceType = reflect.TypeFor[context.Context]()
|
||||
errInterfaceType = reflect.TypeFor[error]()
|
||||
)
|
||||
|
||||
type eventListener struct {
|
||||
id uint64
|
||||
fnVal reflect.Value
|
||||
numIn int
|
||||
numOut int
|
||||
hasCtx bool
|
||||
hasPayload bool
|
||||
argType reflect.Type
|
||||
returnsErr bool
|
||||
returnsVal bool
|
||||
}
|
||||
|
||||
// EventBus is a thread-safe, strongly-typed in-process domain event bus supporting
|
||||
// Emit, Waterfall, Parallel, and Serial dispatch semantics.
|
||||
type EventBus struct {
|
||||
mu sync.RWMutex
|
||||
nextID atomic.Uint64
|
||||
handlers map[string][]eventListener
|
||||
}
|
||||
|
||||
// NewEventBus creates a new EventBus instance.
|
||||
func NewEventBus() *EventBus {
|
||||
return &EventBus{
|
||||
handlers: make(map[string][]eventListener),
|
||||
}
|
||||
}
|
||||
|
||||
// On registers an event handler for the given topic.
|
||||
//
|
||||
// Supported handler signatures:
|
||||
// - func(ctx context.Context, event T) (T, error)
|
||||
// - func(ctx context.Context, event T) T
|
||||
// - func(event T) (T, error)
|
||||
// - func(event T) T
|
||||
// - func(ctx context.Context, event T) error
|
||||
// - func(ctx context.Context, event T)
|
||||
// - func(event T) error
|
||||
// - func(event T)
|
||||
// - func(ctx context.Context) error
|
||||
// - func(ctx context.Context)
|
||||
// - func() error
|
||||
// - func()
|
||||
//
|
||||
// Returns a Disposer function that unregisters the handler when called.
|
||||
func (b *EventBus) On(topic string, handler any) Disposer {
|
||||
if handler == nil {
|
||||
panic("core/events: handler cannot be nil")
|
||||
}
|
||||
|
||||
fnVal := reflect.ValueOf(handler)
|
||||
fnType := fnVal.Type()
|
||||
|
||||
if fnType.Kind() != reflect.Func {
|
||||
panic(fmt.Sprintf("core/events: expected func, got %s", fnType.Kind()))
|
||||
}
|
||||
|
||||
numIn := fnType.NumIn()
|
||||
if numIn > maxHandlerParams {
|
||||
panic(fmt.Sprintf("core/events: handler has %d parameters, maximum 2 supported (ctx, event)", numIn))
|
||||
}
|
||||
|
||||
const maxHandlerReturnValues = 2
|
||||
numOut := fnType.NumOut()
|
||||
if numOut > maxHandlerReturnValues {
|
||||
panic(fmt.Sprintf("core/events: handler has %d return values, maximum 2 supported (value, error)", numOut))
|
||||
}
|
||||
|
||||
returnsErr := false
|
||||
returnsVal := false
|
||||
|
||||
switch numOut {
|
||||
case 1:
|
||||
out0 := fnType.Out(0)
|
||||
if out0.Implements(errInterfaceType) {
|
||||
returnsErr = true
|
||||
} else {
|
||||
returnsVal = true
|
||||
}
|
||||
case 2:
|
||||
out1 := fnType.Out(1)
|
||||
if !out1.Implements(errInterfaceType) {
|
||||
panic(fmt.Sprintf("core/events: second return value must be error, got %v", out1))
|
||||
}
|
||||
returnsVal = true
|
||||
returnsErr = true
|
||||
}
|
||||
|
||||
listener := eventListener{
|
||||
id: b.nextID.Add(1),
|
||||
fnVal: fnVal,
|
||||
numIn: numIn,
|
||||
numOut: numOut,
|
||||
returnsErr: returnsErr,
|
||||
returnsVal: returnsVal,
|
||||
}
|
||||
|
||||
switch numIn {
|
||||
case 0:
|
||||
// func() or func() error
|
||||
case 1:
|
||||
in0 := fnType.In(0)
|
||||
if in0.Implements(ctxInterfaceType) {
|
||||
listener.hasCtx = true
|
||||
} else {
|
||||
listener.hasPayload = true
|
||||
listener.argType = in0
|
||||
}
|
||||
case 2:
|
||||
in0 := fnType.In(0)
|
||||
if !in0.Implements(ctxInterfaceType) {
|
||||
panic(fmt.Sprintf("core/events: first parameter must implement context.Context, got %v", in0))
|
||||
}
|
||||
listener.hasCtx = true
|
||||
listener.hasPayload = true
|
||||
listener.argType = fnType.In(1)
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
b.handlers[topic] = append(b.handlers[topic], listener)
|
||||
b.mu.Unlock()
|
||||
|
||||
listenerID := listener.id
|
||||
var disposed atomic.Bool
|
||||
|
||||
return func() error {
|
||||
if disposed.Swap(true) {
|
||||
return nil
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
list := b.handlers[topic]
|
||||
for i, l := range list {
|
||||
if l.id == listenerID {
|
||||
b.handlers[topic] = append(list[:i], list[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(b.handlers[topic]) == 0 {
|
||||
delete(b.handlers, topic)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a strongly-typed generic event listener on the given EventBus.
|
||||
func Subscribe[T any](bus *EventBus, topic string, handler func(ctx context.Context, event T) error) Disposer {
|
||||
if bus == nil {
|
||||
panic("core/events: nil EventBus provided to Subscribe")
|
||||
}
|
||||
return bus.On(topic, handler)
|
||||
}
|
||||
|
||||
// Emit publishes an event to all subscribers of the specified topic.
|
||||
// Handlers are executed synchronously. If any handler panics or returns an error,
|
||||
// the error is collected and returned via errors.Join.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
func (b *EventBus) Emit(ctx context.Context, topic string, payload any) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
listeners := b.getListeners(topic)
|
||||
if len(listeners) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var payloadVal reflect.Value
|
||||
if payload != nil {
|
||||
payloadVal = reflect.ValueOf(payload)
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, l := range listeners {
|
||||
args := b.buildArgs(ctx, l, payloadVal)
|
||||
|
||||
err := func() (resErr error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
resErr = fmt.Errorf("core/events: panic in handler for topic %q: %v", topic, r)
|
||||
}
|
||||
}()
|
||||
|
||||
results := l.fnVal.Call(args)
|
||||
if l.returnsErr {
|
||||
errIdx := l.numOut - 1
|
||||
if len(results) > errIdx && !results[errIdx].IsNil() {
|
||||
resErr = results[errIdx].Interface().(error)
|
||||
}
|
||||
}
|
||||
return resErr
|
||||
}()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Waterfall runs handlers sequentially as a transformation pipeline.
|
||||
// The returned value of each handler becomes the payload input for the next handler.
|
||||
// If any handler returns an error or panics, execution aborts immediately.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
func (b *EventBus) Waterfall(ctx context.Context, topic string, initialPayload any) (any, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
listeners := b.getListeners(topic)
|
||||
if len(listeners) == 0 {
|
||||
return initialPayload, nil
|
||||
}
|
||||
|
||||
currentPayload := initialPayload
|
||||
|
||||
for _, l := range listeners {
|
||||
var payloadVal reflect.Value
|
||||
if currentPayload != nil {
|
||||
payloadVal = reflect.ValueOf(currentPayload)
|
||||
}
|
||||
|
||||
args := b.buildArgs(ctx, l, payloadVal)
|
||||
|
||||
var stepVal any
|
||||
var stepErr error
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
stepErr = fmt.Errorf("core/events: panic in waterfall handler for topic %q: %v", topic, r)
|
||||
}
|
||||
}()
|
||||
|
||||
results := l.fnVal.Call(args)
|
||||
if l.returnsErr {
|
||||
errIdx := l.numOut - 1
|
||||
if len(results) > errIdx && !results[errIdx].IsNil() {
|
||||
stepErr = results[errIdx].Interface().(error)
|
||||
}
|
||||
}
|
||||
if stepErr == nil && l.returnsVal && len(results) > 0 {
|
||||
stepVal = results[0].Interface()
|
||||
}
|
||||
}()
|
||||
|
||||
if stepErr != nil {
|
||||
return nil, stepErr
|
||||
}
|
||||
|
||||
if l.returnsVal {
|
||||
currentPayload = stepVal
|
||||
}
|
||||
}
|
||||
|
||||
return currentPayload, nil
|
||||
}
|
||||
|
||||
// Parallel executes all subscribers of the topic concurrently in separate goroutines.
|
||||
// It waits for all handlers to complete or returns immediately if ctx is cancelled/timed out,
|
||||
// collecting any handler errors via errors.Join.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
func (b *EventBus) Parallel(ctx context.Context, topic string, payload any) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
listeners := b.getListeners(topic)
|
||||
if len(listeners) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var payloadVal reflect.Value
|
||||
if payload != nil {
|
||||
payloadVal = reflect.ValueOf(payload)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, len(listeners))
|
||||
|
||||
for _, l := range listeners {
|
||||
wg.Add(1)
|
||||
go func(listener eventListener) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errCh <- fmt.Errorf("core/events: panic in parallel handler for topic %q: %v", topic, r)
|
||||
}
|
||||
}()
|
||||
|
||||
args := b.buildArgs(ctx, listener, payloadVal)
|
||||
results := listener.fnVal.Call(args)
|
||||
if listener.returnsErr {
|
||||
errIdx := listener.numOut - 1
|
||||
if len(results) > errIdx && !results[errIdx].IsNil() {
|
||||
errCh <- results[errIdx].Interface().(error)
|
||||
}
|
||||
}
|
||||
}(l)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
close(errCh)
|
||||
var errs []error
|
||||
for err := range errCh {
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Serial executes subscribers strictly in sequence.
|
||||
// If any subscriber returns an error or panics, execution stops immediately and returns that error.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
func (b *EventBus) Serial(ctx context.Context, topic string, payload any) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
listeners := b.getListeners(topic)
|
||||
if len(listeners) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var payloadVal reflect.Value
|
||||
if payload != nil {
|
||||
payloadVal = reflect.ValueOf(payload)
|
||||
}
|
||||
|
||||
for _, l := range listeners {
|
||||
args := b.buildArgs(ctx, l, payloadVal)
|
||||
|
||||
var stepErr error
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
stepErr = fmt.Errorf("core/events: panic in serial handler for topic %q: %v", topic, r)
|
||||
}
|
||||
}()
|
||||
|
||||
results := l.fnVal.Call(args)
|
||||
if l.returnsErr {
|
||||
errIdx := l.numOut - 1
|
||||
if len(results) > errIdx && !results[errIdx].IsNil() {
|
||||
stepErr = results[errIdx].Interface().(error)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if stepErr != nil {
|
||||
return stepErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *EventBus) getListeners(topic string) []eventListener {
|
||||
b.mu.RLock()
|
||||
raw := b.handlers[topic]
|
||||
if len(raw) == 0 {
|
||||
b.mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
listeners := make([]eventListener, len(raw))
|
||||
copy(listeners, raw)
|
||||
b.mu.RUnlock()
|
||||
return listeners
|
||||
}
|
||||
|
||||
func (b *EventBus) buildArgs(ctx context.Context, l eventListener, payloadVal reflect.Value) []reflect.Value {
|
||||
if l.numIn == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
args := make([]reflect.Value, 0, l.numIn)
|
||||
if l.hasCtx {
|
||||
args = append(args, reflect.ValueOf(ctx))
|
||||
}
|
||||
|
||||
if l.hasPayload {
|
||||
arg := b.convertPayload(payloadVal, l.argType)
|
||||
args = append(args, arg)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
func (b *EventBus) convertPayload(payloadVal reflect.Value, targetType reflect.Type) reflect.Value {
|
||||
if !payloadVal.IsValid() {
|
||||
return reflect.Zero(targetType)
|
||||
}
|
||||
|
||||
valType := payloadVal.Type()
|
||||
|
||||
// 1. Direct assignable
|
||||
if valType.AssignableTo(targetType) {
|
||||
return payloadVal
|
||||
}
|
||||
|
||||
// 2. Direct convertible
|
||||
if valType.ConvertibleTo(targetType) {
|
||||
return payloadVal.Convert(targetType)
|
||||
}
|
||||
|
||||
// 3. Payload is pointer *T, target expects T
|
||||
if valType.Kind() == reflect.Pointer && valType.Elem().AssignableTo(targetType) {
|
||||
if !payloadVal.IsNil() {
|
||||
return payloadVal.Elem()
|
||||
}
|
||||
return reflect.Zero(targetType)
|
||||
}
|
||||
|
||||
// 4. Payload is value T, target expects *T
|
||||
if targetType.Kind() == reflect.Pointer && valType.AssignableTo(targetType.Elem()) {
|
||||
ptr := reflect.New(valType)
|
||||
ptr.Elem().Set(payloadVal)
|
||||
return ptr
|
||||
}
|
||||
|
||||
// Fallback to zero value of targetType
|
||||
return reflect.Zero(targetType)
|
||||
}
|
||||
|
||||
// Listeners returns the number of active listeners for a topic.
|
||||
func (b *EventBus) Listeners(topic string) int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return len(b.handlers[topic])
|
||||
}
|
||||
|
||||
// Topics returns all topics that have registered listeners.
|
||||
func (b *EventBus) Topics() []string {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
topics := make([]string, 0, len(b.handlers))
|
||||
for t := range b.handlers {
|
||||
topics = append(topics, t)
|
||||
}
|
||||
return topics
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type UserRegisteredEvent struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type OrderCreatedEvent struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
func TestEventBusPublishSubscribe(t *testing.T) {
|
||||
bus := core.NewEventBus()
|
||||
var receivedID string
|
||||
|
||||
disposer := bus.On("user:registered", func(ctx context.Context, e UserRegisteredEvent) error {
|
||||
receivedID = e.UserID
|
||||
return nil
|
||||
})
|
||||
require.NotNil(t, disposer)
|
||||
|
||||
assert.Equal(t, []string{"user:registered"}, bus.Topics())
|
||||
|
||||
err := bus.Emit(context.Background(), "user:registered", UserRegisteredEvent{UserID: "u_999", Username: "alice"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "u_999", receivedID)
|
||||
|
||||
// Emit to empty topic returns nil error
|
||||
err = bus.Emit(nil, "empty:topic", nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestEventBusGenericSubscribe(t *testing.T) {
|
||||
bus := core.NewEventBus()
|
||||
var receivedOrder string
|
||||
|
||||
assert.Panics(t, func() {
|
||||
core.Subscribe[OrderCreatedEvent](nil, "order:created", func(ctx context.Context, e OrderCreatedEvent) error {
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
disposer := core.Subscribe(bus, "order:created", func(ctx context.Context, e OrderCreatedEvent) error {
|
||||
receivedOrder = e.OrderID
|
||||
return nil
|
||||
})
|
||||
require.NotNil(t, disposer)
|
||||
|
||||
err := bus.Emit(context.Background(), "order:created", OrderCreatedEvent{OrderID: "ord_123", Amount: 99.5})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "ord_123", receivedOrder)
|
||||
|
||||
// Test unsubscribe via disposer
|
||||
err = disposer()
|
||||
assert.NoError(t, err)
|
||||
|
||||
receivedOrder = ""
|
||||
err = bus.Emit(context.Background(), "order:created", OrderCreatedEvent{OrderID: "ord_456", Amount: 100})
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, receivedOrder, "handler should not be called after disposal")
|
||||
}
|
||||
|
||||
func TestEventBusHandlerSignatures(t *testing.T) {
|
||||
bus := core.NewEventBus()
|
||||
|
||||
var (
|
||||
calledWithCtxPayloadErr atomic.Bool
|
||||
calledWithCtxPayload atomic.Bool
|
||||
calledWithPayloadErr atomic.Bool
|
||||
calledWithPayload atomic.Bool
|
||||
calledWithCtxErr atomic.Bool
|
||||
calledWithCtx atomic.Bool
|
||||
calledWithNoArgsErr atomic.Bool
|
||||
calledWithNoArgs atomic.Bool
|
||||
)
|
||||
|
||||
bus.On("test:sig", func(ctx context.Context, e UserRegisteredEvent) error {
|
||||
calledWithCtxPayloadErr.Store(true)
|
||||
assert.Equal(t, "u_1", e.UserID)
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.On("test:sig", func(ctx context.Context, e UserRegisteredEvent) {
|
||||
calledWithCtxPayload.Store(true)
|
||||
assert.Equal(t, "u_1", e.UserID)
|
||||
})
|
||||
|
||||
bus.On("test:sig", func(e UserRegisteredEvent) error {
|
||||
calledWithPayloadErr.Store(true)
|
||||
assert.Equal(t, "u_1", e.UserID)
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.On("test:sig", func(e UserRegisteredEvent) {
|
||||
calledWithPayload.Store(true)
|
||||
assert.Equal(t, "u_1", e.UserID)
|
||||
})
|
||||
|
||||
bus.On("test:sig", func(ctx context.Context) error {
|
||||
calledWithCtxErr.Store(true)
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.On("test:sig", func(ctx context.Context) {
|
||||
calledWithCtx.Store(true)
|
||||
})
|
||||
|
||||
bus.On("test:sig", func() error {
|
||||
calledWithNoArgsErr.Store(true)
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.On("test:sig", func() {
|
||||
calledWithNoArgs.Store(true)
|
||||
})
|
||||
|
||||
err := bus.Emit(context.Background(), "test:sig", UserRegisteredEvent{UserID: "u_1", Username: "test"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.True(t, calledWithCtxPayloadErr.Load())
|
||||
assert.True(t, calledWithCtxPayload.Load())
|
||||
assert.True(t, calledWithPayloadErr.Load())
|
||||
assert.True(t, calledWithPayload.Load())
|
||||
assert.True(t, calledWithCtxErr.Load())
|
||||
assert.True(t, calledWithCtx.Load())
|
||||
assert.True(t, calledWithNoArgsErr.Load())
|
||||
assert.True(t, calledWithNoArgs.Load())
|
||||
}
|
||||
|
||||
func TestEventBusPointerAndValueConversion(t *testing.T) {
|
||||
bus := core.NewEventBus()
|
||||
|
||||
var (
|
||||
receivedFromValueToPtr atomic.Bool
|
||||
receivedFromPtrToValue atomic.Bool
|
||||
receivedFromNilPtr atomic.Bool
|
||||
)
|
||||
|
||||
// Handler expects pointer, payload emitted as value
|
||||
bus.On("test:ptr", func(ctx context.Context, e *UserRegisteredEvent) error {
|
||||
if e != nil && e.UserID == "u_ptr" {
|
||||
receivedFromValueToPtr.Store(true)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
err := bus.Emit(context.Background(), "test:ptr", UserRegisteredEvent{UserID: "u_ptr"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, receivedFromValueToPtr.Load())
|
||||
|
||||
// Handler expects value, payload emitted as pointer
|
||||
bus.On("test:val", func(ctx context.Context, e UserRegisteredEvent) error {
|
||||
if e.UserID == "u_val" {
|
||||
receivedFromPtrToValue.Store(true)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
err = bus.Emit(context.Background(), "test:val", &UserRegisteredEvent{UserID: "u_val"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, receivedFromPtrToValue.Load())
|
||||
|
||||
// Handler expects value, payload is nil pointer
|
||||
var nilEvent *UserRegisteredEvent
|
||||
bus.On("test:nil_ptr", func(ctx context.Context, e UserRegisteredEvent) error {
|
||||
assert.Equal(t, "", e.UserID)
|
||||
receivedFromNilPtr.Store(true)
|
||||
return nil
|
||||
})
|
||||
err = bus.Emit(context.Background(), "test:nil_ptr", nilEvent)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, receivedFromNilPtr.Load())
|
||||
|
||||
// Convertible type test (int to int64)
|
||||
var receivedConvert int64
|
||||
bus.On("test:conv", func(e int64) {
|
||||
receivedConvert = e
|
||||
})
|
||||
err = bus.Emit(context.Background(), "test:conv", int(42))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(42), receivedConvert)
|
||||
}
|
||||
|
||||
func TestEventBusErrorCollectionAndPanicRecovery(t *testing.T) {
|
||||
bus := core.NewEventBus()
|
||||
|
||||
errHandler1 := errors.New("handler 1 failed")
|
||||
errHandler2 := errors.New("handler 2 failed")
|
||||
|
||||
bus.On("test:err", func() error {
|
||||
return errHandler1
|
||||
})
|
||||
|
||||
bus.On("test:err", func() {
|
||||
panic("something went horribly wrong")
|
||||
})
|
||||
|
||||
bus.On("test:err", func() error {
|
||||
return errHandler2
|
||||
})
|
||||
|
||||
err := bus.Emit(context.Background(), "test:err", nil)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, errHandler1) || errors.Is(err, errHandler2))
|
||||
assert.Contains(t, err.Error(), "handler 1 failed")
|
||||
assert.Contains(t, err.Error(), "handler 2 failed")
|
||||
assert.Contains(t, err.Error(), "panic")
|
||||
}
|
||||
|
||||
func TestEventBusInvalidHandlerPanics(t *testing.T) {
|
||||
bus := core.NewEventBus()
|
||||
|
||||
assert.Panics(t, func() {
|
||||
bus.On("test:invalid", nil)
|
||||
})
|
||||
|
||||
assert.Panics(t, func() {
|
||||
bus.On("test:invalid", "not-a-func")
|
||||
})
|
||||
|
||||
assert.Panics(t, func() {
|
||||
// More than 2 arguments
|
||||
bus.On("test:invalid", func(a, b, c string) {})
|
||||
})
|
||||
|
||||
assert.Panics(t, func() {
|
||||
// 2 args, but first is not context
|
||||
bus.On("test:invalid", func(a string, b int) {})
|
||||
})
|
||||
|
||||
assert.Panics(t, func() {
|
||||
// More than 2 return values
|
||||
bus.On("test:invalid", func() (int, string, error) { return 0, "", nil })
|
||||
})
|
||||
|
||||
assert.Panics(t, func() {
|
||||
// 2 return values, but second is not error
|
||||
bus.On("test:invalid", func() (int, string) { return 0, "" })
|
||||
})
|
||||
}
|
||||
|
||||
func TestEventBusListenersCountAndDisposerIdempotence(t *testing.T) {
|
||||
bus := core.NewEventBus()
|
||||
|
||||
assert.Equal(t, 0, bus.Listeners("topic1"))
|
||||
|
||||
d1 := bus.On("topic1", func() {})
|
||||
d2 := bus.On("topic1", func() {})
|
||||
assert.Equal(t, 2, bus.Listeners("topic1"))
|
||||
|
||||
_ = d1()
|
||||
assert.Equal(t, 1, bus.Listeners("topic1"))
|
||||
|
||||
// Calling disposer again should be no-op
|
||||
_ = d1()
|
||||
assert.Equal(t, 1, bus.Listeners("topic1"))
|
||||
|
||||
_ = d2()
|
||||
assert.Equal(t, 0, bus.Listeners("topic1"))
|
||||
}
|
||||
|
||||
func TestEventBusConcurrentAccess(t *testing.T) {
|
||||
bus := core.NewEventBus()
|
||||
var wg sync.WaitGroup
|
||||
|
||||
var receivedCount atomic.Int64
|
||||
|
||||
// Concurrently subscribe and emit
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
topic := fmt.Sprintf("topic:%d", idx%5)
|
||||
disposer := bus.On(topic, func(ctx context.Context, e UserRegisteredEvent) error {
|
||||
receivedCount.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
// Emit some events
|
||||
_ = bus.Emit(context.Background(), topic, UserRegisteredEvent{UserID: fmt.Sprintf("u_%d", idx)})
|
||||
|
||||
// Randomly dispose
|
||||
if idx%2 == 0 {
|
||||
_ = disposer()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
topic := fmt.Sprintf("topic:%d", idx%5)
|
||||
_ = bus.Emit(context.Background(), topic, UserRegisteredEvent{UserID: fmt.Sprintf("u_%d", idx)})
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
assert.Greater(t, receivedCount.Load(), int64(0))
|
||||
}
|
||||
|
||||
func TestEventBusWaterfall(t *testing.T) {
|
||||
bus := core.NewEventBus()
|
||||
|
||||
// Handler 1: appends "-first"
|
||||
bus.On("pipeline:transform", func(ctx context.Context, s string) string {
|
||||
return s + "-first"
|
||||
})
|
||||
|
||||
// Handler 2: appends "-second" with error return
|
||||
bus.On("pipeline:transform", func(s string) (string, error) {
|
||||
return s + "-second", nil
|
||||
})
|
||||
|
||||
res, err := bus.Waterfall(context.Background(), "pipeline:transform", "init")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "init-first-second", res)
|
||||
|
||||
// Test short-circuit on error
|
||||
expectedErr := errors.New("waterfall step failed")
|
||||
bus.On("pipeline:error", func(s string) (string, error) {
|
||||
return s, expectedErr
|
||||
})
|
||||
bus.On("pipeline:error", func(s string) string {
|
||||
return s + "-should-not-run"
|
||||
})
|
||||
|
||||
res, err = bus.Waterfall(context.Background(), "pipeline:error", "start")
|
||||
assert.ErrorIs(t, err, expectedErr)
|
||||
assert.Nil(t, res)
|
||||
}
|
||||
|
||||
func TestEventBusParallel(t *testing.T) {
|
||||
bus := core.NewEventBus()
|
||||
|
||||
var counter atomic.Int64
|
||||
err1 := errors.New("parallel err 1")
|
||||
|
||||
bus.On("test:parallel", func(ctx context.Context, val int) error {
|
||||
counter.Add(int64(val))
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.On("test:parallel", func(val int) error {
|
||||
counter.Add(int64(val))
|
||||
return err1
|
||||
})
|
||||
|
||||
err := bus.Parallel(context.Background(), "test:parallel", 10)
|
||||
assert.ErrorIs(t, err, err1)
|
||||
assert.Equal(t, int64(20), counter.Load())
|
||||
}
|
||||
|
||||
func TestEventBusParallelContextTimeout(t *testing.T) {
|
||||
bus := core.NewEventBus()
|
||||
|
||||
bus.On("test:timeout", func(ctx context.Context) error {
|
||||
select {
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err := bus.Parallel(ctx, "test:timeout", nil)
|
||||
assert.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
func TestEventBusSerial(t *testing.T) {
|
||||
bus := core.NewEventBus()
|
||||
|
||||
var executed []int
|
||||
errStop := errors.New("serial stop")
|
||||
|
||||
bus.On("test:serial", func() error {
|
||||
executed = append(executed, 1)
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.On("test:serial", func() error {
|
||||
executed = append(executed, 2)
|
||||
return errStop
|
||||
})
|
||||
|
||||
bus.On("test:serial", func() error {
|
||||
executed = append(executed, 3)
|
||||
return nil
|
||||
})
|
||||
|
||||
err := bus.Serial(context.Background(), "test:serial", nil)
|
||||
assert.ErrorIs(t, err, errStop)
|
||||
assert.Equal(t, []int{1, 2}, executed)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package extpoints
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the configuration extension point.
|
||||
var (
|
||||
// ErrConfigConflict is returned when the same key is declared with disagreeing metadata.
|
||||
ErrConfigConflict = errors.New("extpoints: conflicting configuration declarations")
|
||||
|
||||
// ErrConfigType is returned when a value cannot be converted to the declared type.
|
||||
ErrConfigType = errors.New("extpoints: configuration value type mismatch")
|
||||
|
||||
// ErrConfigUnknownKey is returned when a configuration key was never declared.
|
||||
ErrConfigUnknownKey = errors.New("extpoints: unknown configuration key")
|
||||
|
||||
// ErrConfigNotResolved is returned when typed reads happen before resolution.
|
||||
ErrConfigNotResolved = errors.New("extpoints: configuration not resolved; run App.Prepare first")
|
||||
|
||||
// ErrConfigTarget is returned when a binding target is not an addressable struct pointer.
|
||||
ErrConfigTarget = errors.New("extpoints: configuration binding target must be a non-nil struct pointer")
|
||||
|
||||
// ErrConfigNoSource is returned when resolution is attempted without a registered source.
|
||||
ErrConfigNoSource = errors.New("extpoints: no configuration source registered")
|
||||
)
|
||||
|
||||
// Configuration origin labels reported by ConfigView.Origin and ConfigEntry.Origin.
|
||||
const (
|
||||
// OriginEnv marks a value that came from an environment variable.
|
||||
OriginEnv = "env"
|
||||
// OriginAutoEnable marks a boolean enabled by the presence of another environment variable.
|
||||
OriginAutoEnable = "auto-enable"
|
||||
// OriginFile marks a value that came from the configuration file.
|
||||
OriginFile = "file"
|
||||
// OriginDefault marks a value that came from a declaration default.
|
||||
OriginDefault = "default"
|
||||
)
|
||||
|
||||
// RedactedValue replaces the printed value of keys declared with secret:"true".
|
||||
const RedactedValue = "******"
|
||||
|
||||
// durationType distinguishes time.Duration from plain int64 during tag walking and decoding.
|
||||
var durationType = reflect.TypeFor[time.Duration]()
|
||||
|
||||
// ConfigRegistry must satisfy the full extension contract, so a missing accessor is a
|
||||
// compile error rather than a runtime surprise inside a plugin Apply.
|
||||
var _ ConfigExtension = (*ConfigRegistry)(nil)
|
||||
|
||||
// ConfigSource abstracts where raw configuration values come from, keeping the
|
||||
// micro-kernel free of concrete loaders such as viper.
|
||||
type ConfigSource interface {
|
||||
// Lookup returns the raw value stored at a dotted path in the configuration file.
|
||||
Lookup(path string) (any, bool)
|
||||
// LookupEnv returns the raw value of an environment variable.
|
||||
LookupEnv(name string) (string, bool)
|
||||
// Describe returns a human readable identity for the source, used in diagnostics.
|
||||
Describe() string
|
||||
}
|
||||
|
||||
// ConfigBinding declares that a plugin reads every `config` tagged field of Target
|
||||
// under a dotted configuration prefix.
|
||||
type ConfigBinding struct {
|
||||
// Prefix is the dotted configuration path, e.g. "redis". An empty prefix means
|
||||
// each field's `config` tag is already a full path.
|
||||
Prefix string
|
||||
// Target must be a non-nil pointer to a struct carrying `config` tags.
|
||||
Target any
|
||||
}
|
||||
|
||||
// configField is a single leaf discovered while walking a binding struct's tags.
|
||||
// key is the fully qualified dotted path used for resolution; path is the raw `config`
|
||||
// tag value used to locate the Go field again during Bind.
|
||||
type configField struct {
|
||||
key string
|
||||
path string
|
||||
env string
|
||||
autoEnable string
|
||||
def string
|
||||
secret bool
|
||||
typ reflect.Type
|
||||
}
|
||||
|
||||
// configDecl is the registered form of a configField, attributed to its declaring plugin.
|
||||
type configDecl struct {
|
||||
key string
|
||||
pluginID string
|
||||
env string
|
||||
autoEnable string
|
||||
def string
|
||||
secret bool
|
||||
typ reflect.Type
|
||||
}
|
||||
|
||||
// ConfigEntry is a redacted, self-describing view of one effective configuration key.
|
||||
type ConfigEntry struct {
|
||||
Key string
|
||||
PluginID string
|
||||
Env string
|
||||
Origin string
|
||||
Value string
|
||||
}
|
||||
|
||||
// ConfigView is the read-only surface over effective configuration values.
|
||||
// Keys are dotted paths such as "redis.enabled".
|
||||
type ConfigView interface {
|
||||
Value(key string) (any, bool)
|
||||
String(key, fallback string) string
|
||||
Bool(key string, fallback bool) bool
|
||||
Int(key string, fallback int) int
|
||||
Duration(key string, fallback time.Duration) time.Duration
|
||||
Strings(key string) []string
|
||||
WasSet(envName string) bool
|
||||
Origin(key string) string
|
||||
}
|
||||
|
||||
// ConfigExtension is the plugin-facing configuration extension point mounted on the
|
||||
// root Context and shared by every forked plugin scope.
|
||||
type ConfigExtension interface {
|
||||
ConfigView
|
||||
|
||||
// SetSource installs the raw value source after construction, letting the composition
|
||||
// root build the adapter once the kernel Context already exists.
|
||||
SetSource(src ConfigSource)
|
||||
// Declare registers plugin-owned configuration bindings before Apply runs.
|
||||
Declare(pluginID string, bindings ...ConfigBinding) error
|
||||
// Bind resolves and assigns the configuration values for a tagged struct.
|
||||
Bind(prefix string, target any) error
|
||||
// Resolve computes the effective value of every declared key once.
|
||||
Resolve() error
|
||||
// Resolved reports whether Resolve has already run.
|
||||
Resolved() bool
|
||||
// Entries returns the redacted effective configuration ordered by key.
|
||||
Entries() []ConfigEntry
|
||||
}
|
||||
|
||||
// ConfigRegistry implements ConfigExtension. Declarations are additive; values are
|
||||
// computed once by Resolve and reused by every later read.
|
||||
type ConfigRegistry struct {
|
||||
mu sync.RWMutex
|
||||
src ConfigSource
|
||||
decls map[string]*configDecl
|
||||
order []string
|
||||
values map[string]any
|
||||
origins map[string]string
|
||||
resolved bool
|
||||
}
|
||||
|
||||
// NewConfigRegistry creates an empty configuration registry. A nil src is allowed so
|
||||
// that the kernel can construct the registry before the composition root injects one.
|
||||
func NewConfigRegistry(src ConfigSource) *ConfigRegistry {
|
||||
return &ConfigRegistry{
|
||||
src: src,
|
||||
decls: make(map[string]*configDecl),
|
||||
values: make(map[string]any),
|
||||
origins: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// SetSource installs the raw value source. It is intended for the composition root,
|
||||
// which builds the adapter after the kernel Context already exists.
|
||||
func (r *ConfigRegistry) SetSource(src ConfigSource) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.src = src
|
||||
}
|
||||
|
||||
// Declare registers every `config` tagged leaf of each binding's target struct.
|
||||
// Repeated declarations of the same key are accepted only when their env, default,
|
||||
// auto-enable and secret metadata agree; disagreement is ErrConfigConflict.
|
||||
func (r *ConfigRegistry) Declare(pluginID string, bindings ...ConfigBinding) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
for _, b := range bindings {
|
||||
if err := r.declareBinding(pluginID, b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ConfigRegistry) declareBinding(pluginID string, b ConfigBinding) error {
|
||||
target, err := bindingStruct(b.Target, b.Prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fields, err := walkConfigFields(target.Type(), b.Prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range fields {
|
||||
if err := r.addDecl(pluginID, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bindingStruct validates that a binding or bind target is a usable struct pointer.
|
||||
func bindingStruct(target any, prefix string) (reflect.Value, error) {
|
||||
rv := reflect.ValueOf(target)
|
||||
if !rv.IsValid() || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
|
||||
return reflect.Value{}, fmt.Errorf("%w: prefix %q received %T", ErrConfigTarget, prefix, target)
|
||||
}
|
||||
return rv.Elem(), nil
|
||||
}
|
||||
|
||||
// walkConfigFields collects leaf configuration declarations from `config` tagged fields.
|
||||
// A field without a `config` tag is skipped, except for embedded structs which are
|
||||
// recursed into so their own tags resolve under the same prefix.
|
||||
func walkConfigFields(t reflect.Type, prefix string) ([]configField, error) {
|
||||
var out []configField
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
sf := t.Field(i)
|
||||
if sf.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
path := sf.Tag.Get("config")
|
||||
if path == "-" {
|
||||
continue
|
||||
}
|
||||
if path == "" {
|
||||
if sf.Type.Kind() == reflect.Struct && sf.Type != durationType {
|
||||
nested, err := walkConfigFields(sf.Type, prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, nested...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, configField{
|
||||
key: joinKey(prefix, path),
|
||||
path: path,
|
||||
env: sf.Tag.Get("env"),
|
||||
autoEnable: sf.Tag.Get("autoEnable"),
|
||||
def: sf.Tag.Get("default"),
|
||||
secret: strings.EqualFold(sf.Tag.Get("secret"), "true"),
|
||||
typ: sf.Type,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func joinKey(prefix, path string) string {
|
||||
if prefix == "" {
|
||||
return path
|
||||
}
|
||||
return prefix + "." + path
|
||||
}
|
||||
|
||||
// addDecl records one leaf, enforcing the shared-declaration consistency rule.
|
||||
func (r *ConfigRegistry) addDecl(pluginID string, f configField) error {
|
||||
if existing, ok := r.decls[f.key]; ok {
|
||||
if existing.env != f.env || existing.def != f.def ||
|
||||
existing.autoEnable != f.autoEnable || existing.secret != f.secret {
|
||||
return fmt.Errorf(
|
||||
"%w: key %q declared by plugin %q and plugin %q with disagreeing env/default/autoEnable/secret metadata",
|
||||
ErrConfigConflict, f.key, existing.pluginID, pluginID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
r.decls[f.key] = &configDecl{
|
||||
key: f.key, pluginID: pluginID, env: f.env,
|
||||
autoEnable: f.autoEnable, def: f.def, secret: f.secret, typ: f.typ,
|
||||
}
|
||||
r.order = append(r.order, f.key)
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user