Publish curated projectbrain source from 88fab8df1542019289e947f092c2b387e0825c60
This commit is contained in:
1 parent
48e72d07e0
commit
eca2891b07
45 files changed
+2686
-317
No files matched your search
@@ -43,6 +43,8 @@ PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES=1440
|
|||||||
PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES=true
|
PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES=true
|
||||||
PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS=60
|
PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS=60
|
||||||
PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS=5400
|
PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS=5400
|
||||||
|
# A queued Gitea job with a compatible idle runner is considered polling-stalled after this grace period.
|
||||||
|
PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS=120
|
||||||
PROJECTBRAIN_PUBLIC_URL=https://projectbrain.example.com
|
PROJECTBRAIN_PUBLIC_URL=https://projectbrain.example.com
|
||||||
# Optional server-to-server credential used only by the governed ITWorx MCP Hub.
|
# Optional server-to-server credential used only by the governed ITWorx MCP Hub.
|
||||||
PROJECTBRAIN_HUB_SERVICE_TOKEN=
|
PROJECTBRAIN_HUB_SERVICE_TOKEN=
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# Public source export
|
# Public source export
|
||||||
|
|
||||||
This source snapshot was generated from the private canonical repository at revision `a53f99c891eb6bcaf4bbbaa7125d711afad0f844`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
|
This source snapshot was generated from the private canonical repository at revision `88fab8df1542019289e947f092c2b387e0825c60`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
|
||||||
+108
-73
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"sourceRevision": "a53f99c891eb6bcaf4bbbaa7125d711afad0f844",
|
"sourceRevision": "88fab8df1542019289e947f092c2b387e0825c60",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": ".dockerignore",
|
"path": ".dockerignore",
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": ".env.example",
|
"path": ".env.example",
|
||||||
"sha256": "d336770cf506118024859fb5b9759bf6b7845e9c9c97ff29e3d243082d771b32",
|
"sha256": "2a6572404e71feb1ff45d8afbe0e447c16d064753650a8c1b42e80ef507a2a16",
|
||||||
"bytes": 2342
|
"bytes": 2492
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": ".gitattributes",
|
"path": ".gitattributes",
|
||||||
@@ -49,8 +49,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/.env.example",
|
"path": "backend/.env.example",
|
||||||
"sha256": "d8316a5c50407c5fad71cd659cc18a25ced26b49422ed7ce05b791feb7728cc5",
|
"sha256": "d125a56d52a11b74bb5408d37dc49996faf36a9326097ed13ecefa6d2493d4ba",
|
||||||
"bytes": 1632
|
"bytes": 1676
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/.gitignore",
|
"path": "backend/.gitignore",
|
||||||
@@ -229,8 +229,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/config/env.ts",
|
"path": "backend/src/config/env.ts",
|
||||||
"sha256": "c9de76c76754fe0625c192790ced90828efb1cccf2b5e4a2a99074f045eee8d5",
|
"sha256": "0bb0c3912cb25fa025a8c8515bb91b0c6a1df9817f7e7e6670a93c701b003cef",
|
||||||
"bytes": 9132
|
"bytes": 9324
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/db/prisma.ts",
|
"path": "backend/src/db/prisma.ts",
|
||||||
@@ -309,13 +309,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/integrations/gitea/client.ts",
|
"path": "backend/src/integrations/gitea/client.ts",
|
||||||
"sha256": "642e4260e52c5ede4eff5596ef66f36d990c991a40a75cd611099d2975fbccb4",
|
"sha256": "f5b8e15f010582a06d241e7c316931a84ecff767165a035f090fb14385570642",
|
||||||
"bytes": 16274
|
"bytes": 16437
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/integrations/gitea/policy.ts",
|
"path": "backend/src/integrations/gitea/policy.ts",
|
||||||
"sha256": "a6c2fe82f9ca0d9a4420922cdff014945cc88a101cf38a369712f40dd415ae5e",
|
"sha256": "4c9d46a0b75fa235eda9e96ddfff72f975b6015da735c009fd10186d6a7ba6db",
|
||||||
"bytes": 4676
|
"bytes": 5628
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/integrations/gitea/portfolio.ts",
|
"path": "backend/src/integrations/gitea/portfolio.ts",
|
||||||
@@ -339,8 +339,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/mcp/autonomyServer.ts",
|
"path": "backend/src/mcp/autonomyServer.ts",
|
||||||
"sha256": "a92e7714865862c7f218cc04f5eee23ec8fd6d51fecc9987703c165523d69240",
|
"sha256": "c1d0f515e5d1da01807ff3c32a8451b214c963dadffdccb313c7dbde0463c69c",
|
||||||
"bytes": 22572
|
"bytes": 27115
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/mcp/featureChangeset.ts",
|
"path": "backend/src/mcp/featureChangeset.ts",
|
||||||
@@ -364,13 +364,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/mcp/server.ts",
|
"path": "backend/src/mcp/server.ts",
|
||||||
"sha256": "cadbf35742b2857c302acce4d770dc31fb7887b9dc43ad40af44fbaee7fcf009",
|
"sha256": "988dbb35051a854e71a92f08e34cb4b8ef415467f7857a28bab82f337e151258",
|
||||||
"bytes": 78413
|
"bytes": 78383
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/mcp/skills.ts",
|
"path": "backend/src/mcp/skills.ts",
|
||||||
"sha256": "62f72fff51f45af2f88e52cfc6ee53758298866bb6a8124c585b1dcc18af219f",
|
"sha256": "40a9990e37c8e31abb530dbdc8ea6026a9aafb6e762491a972148f52a0ab580f",
|
||||||
"bytes": 10511
|
"bytes": 11426
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/mcp/widget.ts",
|
"path": "backend/src/mcp/widget.ts",
|
||||||
@@ -409,8 +409,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/repositories/autonomyRepository.ts",
|
"path": "backend/src/repositories/autonomyRepository.ts",
|
||||||
"sha256": "4afd9ae0d74773bb1d9c8371f430699b95b62ea965570127479faea3d70c2bf3",
|
"sha256": "e1e16ca9ac8976d08eaf56b46876a970f8bb7a99ea6fcc578ed331825bb0efd0",
|
||||||
"bytes": 43286
|
"bytes": 42376
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/src/repositories/autonomyTypes.ts",
|
||||||
|
"sha256": "39d6bfb5f968828c0edb9914a27b7cb9789643670a110cbe9408fad2c005c6f5",
|
||||||
|
"bytes": 3291
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/repositories/blueprintsRepository.ts",
|
"path": "backend/src/repositories/blueprintsRepository.ts",
|
||||||
@@ -504,8 +509,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/routes/activity.ts",
|
"path": "backend/src/routes/activity.ts",
|
||||||
"sha256": "60dac17926b995b1e2ffd6ac6775effd76c6a063b88e8c864fddfedbe8913745",
|
"sha256": "2f17a7e26c3a33c18f3dca43fbde9e60709b4b962c0de00ab9e386fc5520c6c8",
|
||||||
"bytes": 12117
|
"bytes": 12844
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/routes/aiActions.ts",
|
"path": "backend/src/routes/aiActions.ts",
|
||||||
@@ -524,8 +529,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/routes/autonomy.ts",
|
"path": "backend/src/routes/autonomy.ts",
|
||||||
"sha256": "67003ed371d177b239731cdc6f5f0562f199a2c0b6bfc9dfa13e7fc62cf17449",
|
"sha256": "70cb320dcf00c6d83b34838d6693977d4d51a3300425b2f02eb4a885eef7d606",
|
||||||
"bytes": 12746
|
"bytes": 14023
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/routes/blueprints.ts",
|
"path": "backend/src/routes/blueprints.ts",
|
||||||
@@ -584,8 +589,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/routes/giteaIntegration.ts",
|
"path": "backend/src/routes/giteaIntegration.ts",
|
||||||
"sha256": "b03bab5dcd951f6160c7251809f97db936729cec0b9070607a8b0f4d873ad7ca",
|
"sha256": "72ab237e51764caca06633579045af8b9a5d3c79f15984795d81a3a4220315c2",
|
||||||
"bytes": 45151
|
"bytes": 45137
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/routes/health.ts",
|
"path": "backend/src/routes/health.ts",
|
||||||
@@ -629,8 +634,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/routes/operatingSystem.ts",
|
"path": "backend/src/routes/operatingSystem.ts",
|
||||||
"sha256": "91b4a4e8d253c272f05fd0f152efe4fc45e03418bebd511c71663609395b0a0b",
|
"sha256": "462515ee00f4eaf32230cbff9fd76bcfe04ad11bf7f21e2e3b2b6153e693576e",
|
||||||
"bytes": 20391
|
"bytes": 20429
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/routes/p7Runtime.ts",
|
"path": "backend/src/routes/p7Runtime.ts",
|
||||||
@@ -719,8 +724,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/services/activityRuntime.ts",
|
"path": "backend/src/services/activityRuntime.ts",
|
||||||
"sha256": "5ba4b0cb64392564dadc2b46a3d4724883bd6fe96cd9005c1e17b5763463ec34",
|
"sha256": "31160a8c0486f46266b330a76d7eef44e149f8d7fc4a186fac41a7d01a998099",
|
||||||
"bytes": 10117
|
"bytes": 15868
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/services/analysisStrategy.ts",
|
"path": "backend/src/services/analysisStrategy.ts",
|
||||||
@@ -742,6 +747,11 @@
|
|||||||
"sha256": "694272e98f7da3d4d086be42c4602c4a7104f2e21d0cb38eacbc2c52d3e82ba7",
|
"sha256": "694272e98f7da3d4d086be42c4602c4a7104f2e21d0cb38eacbc2c52d3e82ba7",
|
||||||
"bytes": 1967
|
"bytes": 1967
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/src/services/autonomyQueueOrdering.ts",
|
||||||
|
"sha256": "d53b02956c8c82e85ea4222d8439ea68f5480d6256edbabebfb2cffd33ea324e",
|
||||||
|
"bytes": 2112
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/services/autonomyRecovery.ts",
|
"path": "backend/src/services/autonomyRecovery.ts",
|
||||||
"sha256": "d571be091f4a684c0f2bb41e0629faded94ba559cc9ff2265f1bd59f28261d17",
|
"sha256": "d571be091f4a684c0f2bb41e0629faded94ba559cc9ff2265f1bd59f28261d17",
|
||||||
@@ -759,8 +769,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/services/autonomyRuntime.ts",
|
"path": "backend/src/services/autonomyRuntime.ts",
|
||||||
"sha256": "9fe64c370569e5bcdd82de8a033278cb337b79d51ed7c199a62fd1a3accb1b36",
|
"sha256": "53cf372c13ad15794b9a9a3659dbc8148a6efc281906ce56e4c18a1104e86439",
|
||||||
"bytes": 17147
|
"bytes": 17188
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/services/autonomyRuntimeControl.ts",
|
"path": "backend/src/services/autonomyRuntimeControl.ts",
|
||||||
@@ -769,8 +779,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/services/autonomyScheduler.ts",
|
"path": "backend/src/services/autonomyScheduler.ts",
|
||||||
"sha256": "d347b998a1ea84a1207d9a45b3c654648bb3b7061ebfaf747694a61a5f83f8a3",
|
"sha256": "ee426bab718104bdc0a62731efb21a4d850615eaf77195eafd78b342615a15ac",
|
||||||
"bytes": 5338
|
"bytes": 5699
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/services/autonomyWork.ts",
|
"path": "backend/src/services/autonomyWork.ts",
|
||||||
@@ -877,10 +887,20 @@
|
|||||||
"sha256": "f7850ab33bf1735397671271d99d0d2d6dd67cb71ab3434378bd61e43834a352",
|
"sha256": "f7850ab33bf1735397671271d99d0d2d6dd67cb71ab3434378bd61e43834a352",
|
||||||
"bytes": 14958
|
"bytes": 14958
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/src/services/runnerClaimHealth.ts",
|
||||||
|
"sha256": "db6099eb49e63a3e1cfda252d188a9d64234af447633f746219a3444177f6ef2",
|
||||||
|
"bytes": 5669
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/src/services/runnerCompatibility.ts",
|
||||||
|
"sha256": "4ba351995bce12cbfaec9fce008ac763c1348ac5676f7ad1b96ecab121d9dd4b",
|
||||||
|
"bytes": 911
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/services/workGraph.ts",
|
"path": "backend/src/services/workGraph.ts",
|
||||||
"sha256": "081e18915f94e404ae688a992d986d19b271a5815fd8ee22deabb66b9290cbe8",
|
"sha256": "2b20f212f6436581156cc8f27e1ed2d0ba794cd644fde628477583c271a05b02",
|
||||||
"bytes": 41038
|
"bytes": 44039
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/src/types/api.ts",
|
"path": "backend/src/types/api.ts",
|
||||||
@@ -904,8 +924,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/test/activity-runtime.test.ts",
|
"path": "backend/test/activity-runtime.test.ts",
|
||||||
"sha256": "514b2c19069dc219c87af15d227ccfbbbc024226c5772e44d9d39181eae5fa05",
|
"sha256": "20b4fe974f87b7e9afdc415102d5605a4a6110fd3dd79e3a89063228d606e6cc",
|
||||||
"bytes": 7838
|
"bytes": 29613
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/test/analysis-job-capacity.test.ts",
|
"path": "backend/test/analysis-job-capacity.test.ts",
|
||||||
@@ -924,8 +944,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/test/autonomy.test.ts",
|
"path": "backend/test/autonomy.test.ts",
|
||||||
"sha256": "c37274b9912470b84145acd9d58ce3485da5784c7cd79a6d8c9a65747eba6501",
|
"sha256": "147768492fc93460bfcf9c7d7de403c641002aeb022d9a1edf91362ecc36c9bd",
|
||||||
"bytes": 19280
|
"bytes": 20760
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/test/autonomyRecovery.test.ts",
|
"path": "backend/test/autonomyRecovery.test.ts",
|
||||||
@@ -939,8 +959,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/test/autonomyScheduler.test.ts",
|
"path": "backend/test/autonomyScheduler.test.ts",
|
||||||
"sha256": "4edfcba3f357ad49870995c591b008b421d5aab5a6689797302253b930c7d2d6",
|
"sha256": "537019439acf77a42432f1a6157a6c98bdae4df3e38b2430c9989ec2300c95fa",
|
||||||
"bytes": 2285
|
"bytes": 3178
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/test/autonomyWork.test.ts",
|
"path": "backend/test/autonomyWork.test.ts",
|
||||||
@@ -959,8 +979,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/test/database-autonomy.test.ts",
|
"path": "backend/test/database-autonomy.test.ts",
|
||||||
"sha256": "6d12b8b4cc278cef1a186ee0e4e4cb4685ce82c33af9cae9349d54868e7fec55",
|
"sha256": "026e683c392e3b17bfea586327944a030a364b4f3c21312b0b8655aec30fdfcb",
|
||||||
"bytes": 14589
|
"bytes": 47937
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/test/database-repository-sync.test.ts",
|
"path": "backend/test/database-repository-sync.test.ts",
|
||||||
@@ -1004,8 +1024,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/test/gitea-integration.test.ts",
|
"path": "backend/test/gitea-integration.test.ts",
|
||||||
"sha256": "0102bce2bd523d082b26ae20934b85b396efb81b1a41961f0ccf0cb53f2d7ef2",
|
"sha256": "fedc1f48b814c0bbd79c80823f3b76f3210c8935a602e845b133dfc026dc7097",
|
||||||
"bytes": 35212
|
"bytes": 36414
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/test/gitea-portfolio-mcp.test.ts",
|
"path": "backend/test/gitea-portfolio-mcp.test.ts",
|
||||||
@@ -1024,8 +1044,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/test/native-ci-discovery.test.ts",
|
"path": "backend/test/native-ci-discovery.test.ts",
|
||||||
"sha256": "3b193c88110d7d9b2328920edd0211c16803de901cb81f28d921db78d2fbb42f",
|
"sha256": "7b86c426bb31b47f0ea7207896e18bdaaee396a830437c13ed33564945f07746",
|
||||||
"bytes": 4873
|
"bytes": 4926
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/test/operatingSystemRoutes.test.ts",
|
||||||
|
"sha256": "49f8396c724aa07dc56d10339e6f3b1688817c52aa8a69a31d8b6b87c7341385",
|
||||||
|
"bytes": 2164
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/test/operatingSystemServices.test.ts",
|
"path": "backend/test/operatingSystemServices.test.ts",
|
||||||
@@ -1064,8 +1089,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/test/remediation-regressions.test.ts",
|
"path": "backend/test/remediation-regressions.test.ts",
|
||||||
"sha256": "9189c57b5f7dac2a534b9da1c3d64f9ea172e59a828d400874c1acc64e6305ba",
|
"sha256": "c13fcafb7ad72699e7d4aa2a64b555ea4bce6c88fb2ccd425d5154751af14d80",
|
||||||
"bytes": 5236
|
"bytes": 5380
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/test/repository-analysis.test.ts",
|
"path": "backend/test/repository-analysis.test.ts",
|
||||||
@@ -1092,6 +1117,11 @@
|
|||||||
"sha256": "55d5478b8896121fc90e5766977b3432fbf0d44d8bbbca120b21efadaf1df7af",
|
"sha256": "55d5478b8896121fc90e5766977b3432fbf0d44d8bbbca120b21efadaf1df7af",
|
||||||
"bytes": 4495
|
"bytes": 4495
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/test/runnerClaimHealth.test.ts",
|
||||||
|
"sha256": "d4e000dba5a661b86081070deb5735d63cfafbdde84e7aeb97c16c6b61ecda7c",
|
||||||
|
"bytes": 4129
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/test/system-readiness.test.ts",
|
"path": "backend/test/system-readiness.test.ts",
|
||||||
"sha256": "15bfd2d8434d3ec9b1e4cf34256a2fecd3304ebe1865583751612e89d449557b",
|
"sha256": "15bfd2d8434d3ec9b1e4cf34256a2fecd3304ebe1865583751612e89d449557b",
|
||||||
@@ -1134,8 +1164,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "docker-compose.yml",
|
"path": "docker-compose.yml",
|
||||||
"sha256": "40498a29e0553c1bef0095c3994df6e5d7b203e5c1eff81d76b506c32a25e521",
|
"sha256": "49cc781171f6f73cd353e86a23ffe6f0e9557814fde6a4f03db17fe3bbf9c744",
|
||||||
"bytes": 6539
|
"bytes": 6634
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "docker/nginx.conf",
|
"path": "docker/nginx.conf",
|
||||||
@@ -1194,8 +1224,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "e2e/attention-auto-mode.spec.ts",
|
"path": "e2e/attention-auto-mode.spec.ts",
|
||||||
"sha256": "aefee916bd9e33fa0c80ecb2ab80fa2a02494d6f516c1e8d20697cec40199436",
|
"sha256": "7b20f45cefcdb26f6253d51a43dbaebe20fba13651eba2b38baa3e82015c5d9f",
|
||||||
"bytes": 4867
|
"bytes": 9174
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "e2e/core-journeys.spec.ts",
|
"path": "e2e/core-journeys.spec.ts",
|
||||||
@@ -1254,13 +1284,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pnpm-lock.yaml",
|
"path": "pnpm-lock.yaml",
|
||||||
"sha256": "1072b0667e9013f4bf07d340639f76117cc7a9cb52bddb0b5ee338bc71256278",
|
"sha256": "afd7c9990dc30f8071e31429466cb526a6cbc577da752c8f2f1f96466a7bbe44",
|
||||||
"bytes": 237449
|
"bytes": 237487
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pnpm-workspace.yaml",
|
"path": "pnpm-workspace.yaml",
|
||||||
"sha256": "63f93204f0a81b94d599ebd579e093f375f5a92bcd78f87ca66d60179fbe7279",
|
"sha256": "a7a0a18f59047cc34b94900ff0284ad6d32830cd8697abc78873b0038c811ab7",
|
||||||
"bytes": 682
|
"bytes": 722
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "postcss.config.js",
|
"path": "postcss.config.js",
|
||||||
@@ -1987,10 +2017,15 @@
|
|||||||
"sha256": "4766c0de7eb9049eb29e7d4678f986511eb3601a093847c03a915b2a623e6108",
|
"sha256": "4766c0de7eb9049eb29e7d4678f986511eb3601a093847c03a915b2a623e6108",
|
||||||
"bytes": 5277
|
"bytes": 5277
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "src/features/autonomy/components/SortableWorkQueue.tsx",
|
||||||
|
"sha256": "a12ca769974ace3463bf6f51152a4ba067a7611b9d42580c7571aceaf5f8c398",
|
||||||
|
"bytes": 5632
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "src/features/autonomy/pages/AutoMode.tsx",
|
"path": "src/features/autonomy/pages/AutoMode.tsx",
|
||||||
"sha256": "b4b23d9ad9ce1b53d08a1bd3eea581ee318d942d90b5ee9012817211df040e78",
|
"sha256": "76e0b1f365a876729faddf5f5ad804e2557d9a62fce9caba0f337d13962c0651",
|
||||||
"bytes": 23016
|
"bytes": 26528
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/features/blueprint/pages/BlueprintEngine.tsx",
|
"path": "src/features/blueprint/pages/BlueprintEngine.tsx",
|
||||||
@@ -2289,8 +2324,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/features/portfolio/components/PortfolioExecutionPulse.tsx",
|
"path": "src/features/portfolio/components/PortfolioExecutionPulse.tsx",
|
||||||
"sha256": "aadbc725d8d3f23781188c2a39aee8017cb4ea6b22b7f47d3df6aeed5f09fdaf",
|
"sha256": "0b29e5fbd2267c6e8b7ea1b27e9ddebe77a72a62535c82a5f4e8e7f00b8943c9",
|
||||||
"bytes": 11228
|
"bytes": 12059
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/features/portfolio/components/PortfolioPulseSummary.tsx",
|
"path": "src/features/portfolio/components/PortfolioPulseSummary.tsx",
|
||||||
@@ -2299,8 +2334,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/features/portfolio/components/PortfolioRuntimeDashboard.tsx",
|
"path": "src/features/portfolio/components/PortfolioRuntimeDashboard.tsx",
|
||||||
"sha256": "2fc6b66ec4c991603344b0368193d2252d37836edba541f27b58d7c9a70d2634",
|
"sha256": "2d32fbe0aac8ab4d491b27f4e198194e0ea5beaeb774d5aba5714c9513ee07d3",
|
||||||
"bytes": 16223
|
"bytes": 20490
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/features/portfolio/components/PortfolioSavedViews.tsx",
|
"path": "src/features/portfolio/components/PortfolioSavedViews.tsx",
|
||||||
@@ -2314,8 +2349,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/features/portfolio/components/RuntimeJobLane.tsx",
|
"path": "src/features/portfolio/components/RuntimeJobLane.tsx",
|
||||||
"sha256": "0b1bc27f733f6f0b63fb5b6413a1f116079d71b91ef1e28185bb574cef6a5230",
|
"sha256": "8538874c869bcb44ad11e082ae2cabfd575cbdb16fe157259bac8ec5fe2f6e9c",
|
||||||
"bytes": 4216
|
"bytes": 4613
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/features/portfolio/pages/PortfolioCommandCenter.tsx",
|
"path": "src/features/portfolio/pages/PortfolioCommandCenter.tsx",
|
||||||
@@ -2534,8 +2569,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/shared/api/autonomyApi.ts",
|
"path": "src/shared/api/autonomyApi.ts",
|
||||||
"sha256": "93afe8f0a03869c16a49e7cc637af61efce1045c805a4a28913933a82fd6529a",
|
"sha256": "fb86b5a89d3d49d37e075a1ebb1ece61ffe3d67fcae707e46b042e664451634c",
|
||||||
"bytes": 3971
|
"bytes": 4349
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/shared/api/b1ReadBackend.ts",
|
"path": "src/shared/api/b1ReadBackend.ts",
|
||||||
@@ -2589,8 +2624,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/shared/api/portfolioTypes.ts",
|
"path": "src/shared/api/portfolioTypes.ts",
|
||||||
"sha256": "76f630cb4d2ca22388cd095937323c160c6cf2196cbbd2af02ba6c8fd7d53184",
|
"sha256": "a2b105d685934b23fef8b495c4f1c09ecd3230dd10f6593a68ebedc499a27390",
|
||||||
"bytes": 7952
|
"bytes": 8969
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/shared/api/projectBrainClient.ts",
|
"path": "src/shared/api/projectBrainClient.ts",
|
||||||
@@ -2729,13 +2764,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/shared/lib/activityRuntimePresentation.test.ts",
|
"path": "src/shared/lib/activityRuntimePresentation.test.ts",
|
||||||
"sha256": "6292ed092e473e9843f751b3e7a9dc6994da813a2afa6bd89974b89776a00037",
|
"sha256": "f67ba1030988cf1afa4dd16320445ecf26837db1c2c89da40d073359d7f77ff7",
|
||||||
"bytes": 4469
|
"bytes": 8358
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/shared/lib/activityRuntimePresentation.ts",
|
"path": "src/shared/lib/activityRuntimePresentation.ts",
|
||||||
"sha256": "f98421bb8783955d01b5224858595be4ab9fd2e181a376b112adc41f983f2c33",
|
"sha256": "c0178bfabd514bf9230c84c8e7308342bd5b4ad78b01da4d352093db23e50b6c",
|
||||||
"bytes": 4325
|
"bytes": 7868
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/shared/lib/appLogger.ts",
|
"path": "src/shared/lib/appLogger.ts",
|
||||||
|
|||||||
@@ -37,4 +37,5 @@ PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES=1440
|
|||||||
PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES=true
|
PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES=true
|
||||||
PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS=60
|
PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS=60
|
||||||
PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS=5400
|
PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS=5400
|
||||||
|
PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS=120
|
||||||
PROJECTBRAIN_PUBLIC_URL=
|
PROJECTBRAIN_PUBLIC_URL=
|
||||||
@@ -43,6 +43,7 @@ const envSchema = z.object({
|
|||||||
// the ProjectBrain MCP plug-in. Keep a 30-minute grace window so ordinary
|
// the ProjectBrain MCP plug-in. Keep a 30-minute grace window so ordinary
|
||||||
// scheduler jitter does not make a healthy executor appear disconnected.
|
// scheduler jitter does not make a healthy executor appear disconnected.
|
||||||
PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS: z.coerce.number().int().min(60).max(86_400).default(5_400),
|
PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS: z.coerce.number().int().min(60).max(86_400).default(5_400),
|
||||||
|
PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS: z.coerce.number().int().min(30).max(3_600).default(120),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppEnv = z.infer<typeof envSchema>;
|
export type AppEnv = z.infer<typeof envSchema>;
|
||||||
@@ -108,6 +109,7 @@ export const loadEnv = (
|
|||||||
PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES: input.PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES,
|
PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES: input.PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES,
|
||||||
PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS: input.PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS,
|
PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS: input.PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS,
|
||||||
PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS: input.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS,
|
PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS: input.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS,
|
||||||
|
PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS: input.PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (parsed.NODE_ENV === "production" && !parsed.DATABASE_URL) {
|
if (parsed.NODE_ENV === "production" && !parsed.DATABASE_URL) {
|
||||||
|
|||||||
@@ -163,9 +163,10 @@ export class GiteaClient {
|
|||||||
private async getMergeStrategyKey(): Promise<"Do" | "do"> {
|
private async getMergeStrategyKey(): Promise<"Do" | "do"> {
|
||||||
if (this.mergeStrategyKey) return this.mergeStrategyKey;
|
if (this.mergeStrategyKey) return this.mergeStrategyKey;
|
||||||
const { version } = await this.request<{ version?: string }>("/version");
|
const { version } = await this.request<{ version?: string }>("/version");
|
||||||
const match = /^1\.(25|26|27)(?:\.\d+)?(?:[-+].*)?$/.exec(version ?? "");
|
const legacyMatch = /^1\.(14|25|26|27)(?:\.\d+)?(?:[-+].*)?$/.exec(version ?? "");
|
||||||
if (!match) throw new GiteaApiError(500, "GITEA_VERSION_UNSUPPORTED", "Gitea merge compatibility requires an identified 1.25, 1.26, or 1.27 server");
|
const modernMatch = /^(28)(?:\.\d+){1,2}(?:[-+].*)?$/.exec(version ?? "");
|
||||||
this.mergeStrategyKey = match[1] === "25" ? "Do" : "do";
|
if (!legacyMatch && !modernMatch) throw new GiteaApiError(500, "GITEA_VERSION_UNSUPPORTED", "Gitea merge compatibility requires an identified 1.14, 1.25, 1.26, 1.27, or 28.x server");
|
||||||
|
this.mergeStrategyKey = legacyMatch && ["14", "25"].includes(legacyMatch[1] ?? "") ? "Do" : "do";
|
||||||
return this.mergeStrategyKey;
|
return this.mergeStrategyKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,9 +69,29 @@ export const exactHeadActionRuns = (runs: GiteaActionRun[], branch: string, head
|
|||||||
(!pr.head?.ref || pr.head.ref === branch) && (!pr.head?.sha || pr.head.sha === headSha));
|
(!pr.head?.ref || pr.head.ref === branch) && (!pr.head?.sha || pr.head.sha === headSha));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isManagedFullValidationRun = (run: GiteaActionRun): boolean => {
|
||||||
|
const workflowPath = run.path.split("@")[0]?.trim().toLowerCase() ?? "";
|
||||||
|
const title = run.display_title.trim().toLowerCase();
|
||||||
|
return (run.event === "workflow_dispatch" || run.event === "pull_request")
|
||||||
|
&& workflowPath.endsWith("/managed-validation.yml")
|
||||||
|
&& title === "managed validation / full";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const successfulFullActionRun = (runs: GiteaActionRun[], branch: string, headSha: string): GiteaActionRun => {
|
||||||
|
const fullRuns = exactHeadActionRuns(runs, branch, headSha)
|
||||||
|
.filter(isManagedFullValidationRun)
|
||||||
|
.sort((left, right) => right.run_number - left.run_number || right.id - left.id);
|
||||||
|
const latest = fullRuns[0];
|
||||||
|
if (!latest || latest.status !== "completed" || latest.conclusion !== "success") {
|
||||||
|
throw new Error("FEATURE_DELIVERY_FULL_VALIDATION_REQUIRED");
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
};
|
||||||
|
|
||||||
export const assertSuccessfulActionRuns = (runs: GiteaActionRun[], branch: string, headSha: string): GiteaActionRun[] => {
|
export const assertSuccessfulActionRuns = (runs: GiteaActionRun[], branch: string, headSha: string): GiteaActionRun[] => {
|
||||||
const exactRuns = exactHeadActionRuns(runs, branch, headSha);
|
const exactRuns = exactHeadActionRuns(runs, branch, headSha);
|
||||||
if (exactRuns.length === 0) throw new Error("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
|
if (exactRuns.length === 0) throw new Error("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
|
||||||
|
successfulFullActionRun(exactRuns, branch, headSha);
|
||||||
|
|
||||||
// Gitea retains failed attempts when the same workflow is rerun for an
|
// Gitea retains failed attempts when the same workflow is rerun for an
|
||||||
// unchanged revision. Authorization therefore follows the newest run of each
|
// unchanged revision. Authorization therefore follows the newest run of each
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { AppEnv } from "../config/env.js";
|
import type { AppEnv } from "../config/env.js";
|
||||||
import { prisma } from "../db/prisma.js";
|
import { prisma } from "../db/prisma.js";
|
||||||
import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, normalizeAutonomyState, reportAutonomyWork, resumeAutonomy, retryAutonomyBlockerIfDue } from "../repositories/autonomyRepository.js";
|
import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, normalizeAutonomyState, reorderAutonomyQueue, reportAutonomyWork, resetAutonomyQueueOrder, resumeAutonomy, retryAutonomyBlockerIfDue } from "../repositories/autonomyRepository.js";
|
||||||
import { claimAutonomyWork } from "../services/autonomyRuntimeControl.js";
|
import { claimAutonomyWork } from "../services/autonomyRuntimeControl.js";
|
||||||
|
import { createGiteaActivityRuntimeLoader } from "../services/activityRuntime.js";
|
||||||
|
import { evaluateRunnerClaimHealth } from "../services/runnerClaimHealth.js";
|
||||||
import { compactAutonomyState, recordAutonomyExecutorHeartbeat, touchAutonomyExecutorHeartbeat } from "../services/autonomyRuntime.js";
|
import { compactAutonomyState, recordAutonomyExecutorHeartbeat, touchAutonomyExecutorHeartbeat } from "../services/autonomyRuntime.js";
|
||||||
import { syncGitSources } from "../services/gitSourceSync.js";
|
import { syncGitSources } from "../services/gitSourceSync.js";
|
||||||
import { reconcileRepositoryAutonomyRoadmap } from "../services/repositoryAutonomy.js";
|
import { reconcileRepositoryAutonomyRoadmap } from "../services/repositoryAutonomy.js";
|
||||||
@@ -60,6 +62,7 @@ const reportWithConflictRetry = async (
|
|||||||
|
|
||||||
export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => {
|
export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => {
|
||||||
const server = createBaseProjectBrainMcpServer(env, actor);
|
const server = createBaseProjectBrainMcpServer(env, actor);
|
||||||
|
const loadGiteaRuntime = createGiteaActivityRuntimeLoader(env);
|
||||||
|
|
||||||
server.registerTool("list_auto_mode_projects", {
|
server.registerTool("list_auto_mode_projects", {
|
||||||
title: "List ProjectBrain Auto Mode projects",
|
title: "List ProjectBrain Auto Mode projects",
|
||||||
@@ -71,11 +74,15 @@ export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => {
|
|||||||
},
|
},
|
||||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||||
}, async ({ enabled_only, statuses, max_projects }) => {
|
}, async ({ enabled_only, statuses, max_projects }) => {
|
||||||
const projects = await prisma.project.findMany({
|
const [projects, runnerRuntime] = await Promise.all([
|
||||||
|
prisma.project.findMany({
|
||||||
where: { deletedAt: null },
|
where: { deletedAt: null },
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
select: { slug: true, readableId: true, name: true, workspaceState: { select: { notesJson: true } } },
|
select: { slug: true, readableId: true, name: true, workspaceState: { select: { notesJson: true } } },
|
||||||
});
|
}),
|
||||||
|
loadGiteaRuntime(),
|
||||||
|
]);
|
||||||
|
const runnerClaimHealth = evaluateRunnerClaimHealth(runnerRuntime, { stallSeconds: env.PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS });
|
||||||
const selectedStatuses = statuses?.length ? new Set(statuses) : undefined;
|
const selectedStatuses = statuses?.length ? new Set(statuses) : undefined;
|
||||||
const rows = projects.flatMap((project) => {
|
const rows = projects.flatMap((project) => {
|
||||||
const notes = asRecord(project.workspaceState?.notesJson);
|
const notes = asRecord(project.workspaceState?.notesJson);
|
||||||
@@ -117,7 +124,33 @@ export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => {
|
|||||||
retry_at: state.blocker?.retryAt,
|
retry_at: state.blocker?.retryAt,
|
||||||
}];
|
}];
|
||||||
}).sort(compareAutoModeProjectRows).slice(0, max_projects);
|
}).sort(compareAutoModeProjectRows).slice(0, max_projects);
|
||||||
return result({ projects: rows }, `${rows.length} Auto Mode project(s) matched. Claimable projects are ordered fairly by oldest last run (never-run projects first), so select the first row and keep processing claim_auto_work → execute → report_auto_work until that project's item or time budget is reached, work becomes busy or terminal, or user action is required.`);
|
const recoveryText = [
|
||||||
|
runnerClaimHealth.status === "stalled"
|
||||||
|
? "Runner claim-health is stalled; recovery ownership remains with the governed AppOps runner-fleet controller. Do not restart a still-running runner directly from ProjectBrain."
|
||||||
|
: "",
|
||||||
|
!runnerClaimHealth.runnerInventoryComplete
|
||||||
|
? "Runner inventory is unavailable; do not infer label compatibility, capacity, or polling stalls from missing runner data."
|
||||||
|
: "",
|
||||||
|
!runnerClaimHealth.coverage.complete
|
||||||
|
? `Runner claim-health scan is incomplete (${runnerClaimHealth.coverage.observed} queued job(s) observed of ${runnerClaimHealth.coverage.total ?? "unknown"}); do not infer the unobserved queue is healthy.`
|
||||||
|
: "",
|
||||||
|
].filter(Boolean).map((text) => ` ${text}`).join("");
|
||||||
|
return result({ projects: rows, runner_claim_health: runnerClaimHealth }, `${rows.length} Auto Mode project(s) matched. Claimable projects are ordered fairly by oldest last run (never-run projects first), so select the first row and keep processing claim_auto_work → execute → report_auto_work until that project's item or time budget is reached, work becomes busy or terminal, or user action is required.${recoveryText}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.registerTool("get_runner_claim_health", {
|
||||||
|
title: "Get ProjectBrain runner claim health",
|
||||||
|
description: "Diagnose queued Gitea jobs against live runner labels and capacity. This surface is read-only: ProjectBrain distinguishes claim grace, capacity wait, label mismatch, polling stalls and incomplete runner inventory, while the governed AppOps runner-fleet controller remains the only recovery authority.",
|
||||||
|
inputSchema: {},
|
||||||
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||||
|
}, async () => {
|
||||||
|
const runtime = await loadGiteaRuntime();
|
||||||
|
const claimHealth = evaluateRunnerClaimHealth(runtime, { stallSeconds: env.PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS });
|
||||||
|
const coverage = claimHealth.coverage.complete
|
||||||
|
? "queued-job scan complete"
|
||||||
|
: `queued-job scan incomplete (${claimHealth.coverage.observed} observed of ${claimHealth.coverage.total ?? "unknown"})`;
|
||||||
|
const inventory = claimHealth.runnerInventoryComplete ? "runner inventory complete" : "runner inventory unavailable; job diagnoses are unknown";
|
||||||
|
return result({ runner_claim_health: claimHealth }, `Runner claim health is ${claimHealth.status}; ${coverage}; ${inventory}; oldest runnable observed wait=${claimHealth.oldestWaitSeconds}s; oldest continuously claimable wait=${claimHealth.oldestClaimableWaitSeconds}s; recovery owner=${claimHealth.recoveryOwner}.`);
|
||||||
});
|
});
|
||||||
|
|
||||||
server.registerTool("heartbeat_auto_executor", {
|
server.registerTool("heartbeat_auto_executor", {
|
||||||
@@ -237,6 +270,29 @@ export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => {
|
|||||||
return result({ state: state as unknown as Record<string, unknown> }, `Roadmap '${roadmap.title}' imported with ${roadmap.phases.length} phase(s) and ${roadmap.phases.reduce((count, phase) => count + phase.items.length, 0)} item(s).`);
|
return result({ state: state as unknown as Record<string, unknown> }, `Roadmap '${roadmap.title}' imported with ${roadmap.phases.length} phase(s) and ${roadmap.phases.reduce((count, phase) => count + phase.items.length, 0)} item(s).`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.registerTool("reorder_auto_queue", {
|
||||||
|
title: "Reorder ProjectBrain Auto Mode queue",
|
||||||
|
description: "Persist an explicit owner-defined order for all currently pending executable Auto Mode work items in one project. Supply the complete current item-id list in desired order. Running/completed/future work is not reorderable and dependency eligibility still wins at claim time.",
|
||||||
|
inputSchema: {
|
||||||
|
project_id: z.string().min(1),
|
||||||
|
item_ids: z.array(z.string().min(1)).max(500),
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||||
|
}, async ({ project_id, item_ids }) => {
|
||||||
|
const state = await reorderAutonomyQueue(project_id, item_ids, actor);
|
||||||
|
return result({ state: compactAutonomyState(state, { itemLimit: 25, eventLimit: 5 }) as unknown as Record<string, unknown> }, `Auto Mode queue reordered; ${item_ids.length} pending executable item(s) now have explicit owner order.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.registerTool("reset_auto_queue_order", {
|
||||||
|
title: "Restore automatic ProjectBrain Auto Mode scheduling",
|
||||||
|
description: "Clear explicit manual ordering for one project's Auto Mode queue so the normal ProjectBrain scheduler again ranks ready work by scope, risk, dependencies, downstream impact and configured scheduling policy.",
|
||||||
|
inputSchema: { project_id: z.string().min(1) },
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||||
|
}, async ({ project_id }) => {
|
||||||
|
const state = await resetAutonomyQueueOrder(project_id, actor);
|
||||||
|
return result({ state: compactAutonomyState(state, { itemLimit: 25, eventLimit: 5 }) as unknown as Record<string, unknown> }, "Automatic Auto Mode scheduling order restored.");
|
||||||
|
});
|
||||||
|
|
||||||
server.registerTool("resume_auto_mode", {
|
server.registerTool("resume_auto_mode", {
|
||||||
title: "Resume blocked ProjectBrain Auto Mode",
|
title: "Resume blocked ProjectBrain Auto Mode",
|
||||||
description: "Clear the current Auto Mode blocker and make its blocked roadmap phase/item claimable again. The roadmap, completed phases and completed work are preserved; only blocker/retry state for the blocked work is reset.",
|
description: "Clear the current Auto Mode blocker and make its blocked roadmap phase/item claimable again. The roadmap, completed phases and completed work are preserved; only blocker/retry state for the blocked work is reset.",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|||||||
import type { AppEnv } from "../config/env.js";
|
import type { AppEnv } from "../config/env.js";
|
||||||
import { prisma } from "../db/prisma.js";
|
import { prisma } from "../db/prisma.js";
|
||||||
import { GiteaApiError } from "../integrations/gitea/client.js";
|
import { GiteaApiError } from "../integrations/gitea/client.js";
|
||||||
import { assertChatGptBranch, assertExpectedFileSha, assertFileSize, assertReadablePath, assertSuccessfulActionRuns, assertWritablePath, exactHeadActionRuns, findReusableDeliveryPullRequest } from "../integrations/gitea/policy.js";
|
import { assertChatGptBranch, assertExpectedFileSha, assertFileSize, assertReadablePath, assertSuccessfulActionRuns, assertWritablePath, exactHeadActionRuns, findReusableDeliveryPullRequest, successfulFullActionRun } from "../integrations/gitea/policy.js";
|
||||||
import { finishExternalOperation, getGiteaClient, listLinkedRepositories, markExternalOperationExecuting, prepareExternalOperation, recordMcpAudit, type LinkedRepository } from "../integrations/gitea/service.js";
|
import { finishExternalOperation, getGiteaClient, listLinkedRepositories, markExternalOperationExecuting, prepareExternalOperation, recordMcpAudit, type LinkedRepository } from "../integrations/gitea/service.js";
|
||||||
import type { GiteaContent } from "../integrations/gitea/types.js";
|
import type { GiteaContent } from "../integrations/gitea/types.js";
|
||||||
import { assertFeatureDeliveryActionEvidence, canSyncFeatureDeliveryValidation, getFeatureDelivery, listFeatureDeliveries, openFeatureDeliveryPullRequest, reconcileFeatureDeliveryPullRequest, recordFeatureDeliveryActionValidation, recordFeatureDeliveryProgress, startFeatureDelivery, updateFeatureDelivery } from "../repositories/featureDeliveryRepository.js";
|
import { assertFeatureDeliveryActionEvidence, canSyncFeatureDeliveryValidation, getFeatureDelivery, listFeatureDeliveries, openFeatureDeliveryPullRequest, reconcileFeatureDeliveryPullRequest, recordFeatureDeliveryActionValidation, recordFeatureDeliveryProgress, startFeatureDelivery, updateFeatureDelivery } from "../repositories/featureDeliveryRepository.js";
|
||||||
@@ -717,8 +717,8 @@ export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => {
|
|||||||
const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch);
|
const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch);
|
||||||
const head = await client.getBranch(link!.owner, link!.repository, branch);
|
const head = await client.getBranch(link!.owner, link!.repository, branch);
|
||||||
const response = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: head.commit.id, limit: 30 });
|
const response = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: head.commit.id, limit: 30 });
|
||||||
const verifiedRuns = assertSuccessfulActionRuns(response.workflow_runs, branch, head.commit.id);
|
assertSuccessfulActionRuns(response.workflow_runs, branch, head.commit.id);
|
||||||
const run = verifiedRuns.reduce((latest, candidate) => candidate.run_number > latest.run_number ? candidate : latest);
|
const run = successfulFullActionRun(response.workflow_runs, branch, head.commit.id);
|
||||||
const evidence = { provider: "gitea_actions" as const, runId: run.id, runNumber: run.run_number, url: run.html_url, workflowPath: run.path, headSha: run.head_sha, conclusion: "success" as const, verifiedAt: new Date().toISOString() };
|
const evidence = { provider: "gitea_actions" as const, runId: run.id, runNumber: run.run_number, url: run.html_url, workflowPath: run.path, headSha: run.head_sha, conclusion: "success" as const, verifiedAt: new Date().toISOString() };
|
||||||
return asResult({ run, delivery: await recordFeatureDeliveryActionValidation(project_id, delivery_id, evidence, actor) }, "Gitea Actions validation verified and persisted");
|
return asResult({ run, delivery: await recordFeatureDeliveryActionValidation(project_id, delivery_id, evidence, actor) }, "Gitea Actions validation verified and persisted");
|
||||||
}, true));
|
}, true));
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const definitions: SkillDefinition[] = [
|
|||||||
{
|
{
|
||||||
name: "projectbrain-operator",
|
name: "projectbrain-operator",
|
||||||
description: "Operate Auto Mode with ProjectBrain as controller and Gitea as the canonical repository execution and evidence surface.",
|
description: "Operate Auto Mode with ProjectBrain as controller and Gitea as the canonical repository execution and evidence surface.",
|
||||||
body: `# ProjectBrain operator\n\n1. Start with projectbrain.project.list and projectbrain.context.get when the ProjectBrain connector is available. Durable ProjectBrain state and repository planning files, not chat history, are the source of truth.\n2. ProjectBrain is the controller: it owns Auto policy, roadmap state, dependencies, recovery and human-attention decisions. Gitea is the only primary repository execution surface. ChatGPT may orchestrate and reason, but code changes must travel through governed Gitea branches, validation, pull requests and merge evidence. No OpenAI API key is required for this operating model.\n3. For implementation work, use the canonical branch returned as work.execution.branch. When working directly from repository planning truth without a ProjectBrain claim call, derive the same branch as projectbrain/auto/<normalized-work-id>: lowercase the durable task id and replace non alphanumeric, dot, underscore or hyphen runs with hyphens. The existence of that branch is the atomic execution claim. If it already exists, resume it; never create a competing branch for the same task.\n4. Load repository context from the current default branch, then resume or create the canonical non-default work branch. Use the Gitea workspace changeset flow for edits, run the narrowest managed validation profile and a full profile before merge when required, open or reuse the PR, confirm exact-head validation evidence, review the diff, and merge the exact validated head. Direct default-branch writes remain forbidden.\n5. An open PR is unfinished. A work item is implementation-complete only when its canonical PR is merged and the exact PR head has successful required Gitea Actions evidence. ProjectBrain then reconciles that evidence and downstream state back into Auto Mode.\n6. heartbeat_auto_executor, claim_auto_work and report_auto_work remain backward-compatible controller coordination tools. They are not the repository correctness authority. A missing or interrupted report_auto_work callback must never invalidate a valid Gitea branch/PR/validation/merge chain. When a compatibility lease is present, reuse it; after Gitea reconciliation a late report is an idempotent replay.\n7. For brainstorm or phase-review work, inspect ProjectBrain context plus repository roadmap/backlog, findings and evidence. Persist actionable implementation work into repository planning truth or ProjectBrain durable tasks, then execute resulting code work through the same canonical Gitea path. Do not invent routine approvals.\n8. Treat transient runner, Gitea, network and provider failures as retryable. Escalate to human attention only for credentials, policy authorization, destructive risk or a genuine product decision. Local models such as Ollama are optional helpers when explicitly permitted; they do not change the Gitea execution contract.\n`,
|
body: `# ProjectBrain operator\n\n1. Start with projectbrain.project.list and projectbrain.context.get when the ProjectBrain connector is available. Durable ProjectBrain state and repository planning files, not chat history, are the source of truth.\n2. ProjectBrain is the controller: it owns Auto policy, roadmap state, dependencies, recovery and human-attention decisions. Gitea is the only primary repository execution surface. ChatGPT may orchestrate and reason, but code changes must travel through governed Gitea branches, validation, pull requests and merge evidence. No OpenAI API key is required for this operating model.\n3. For implementation work, use the canonical branch returned as work.execution.branch. When working directly from repository planning truth without a ProjectBrain claim call, derive the same branch as projectbrain/auto/<normalized-work-id>: lowercase the durable task id and replace non alphanumeric, dot, underscore or hyphen runs with hyphens. The existence of that branch is the atomic execution claim. If it already exists, resume it; never create a competing branch for the same task.\n4. Load repository context from the current default branch, then resume or create the canonical non-default work branch. During implementation, use the coding workspace for the narrowest relevant tests after each behavioral slice and broader local regression at coherent checkpoints. Do not dispatch managed Gitea validation for every slice, subcommit, or small edit, and do not push solely to obtain runner evidence. Use a remote narrow profile only when the needed environment is unavailable locally. Dispatch one managed full profile when the exact branch HEAD is genuinely merge-ready; if that HEAD changes, treat the prior full result as stale and wait until the new HEAD is merge-ready before rerunning it. Open or reuse the PR, confirm exact-head full evidence, review the diff, and merge that exact validated head. Direct default-branch writes remain forbidden.\n5. An open PR is unfinished. A work item is implementation-complete only when its canonical PR is merged and the exact PR head has successful exact-head validation evidence from required Gitea Actions. ProjectBrain then reconciles that evidence and downstream state back into Auto Mode.\n6. heartbeat_auto_executor, claim_auto_work and report_auto_work remain backward-compatible controller coordination tools. They are not the repository correctness authority. A missing or interrupted report_auto_work callback must never invalidate a valid Gitea branch/PR/validation/merge chain. When a compatibility lease is present, reuse it; after Gitea reconciliation a late report is an idempotent replay.\n7. For brainstorm or phase-review work, inspect ProjectBrain context plus repository roadmap/backlog, findings and evidence. Persist actionable implementation work into repository planning truth or ProjectBrain durable tasks, then execute resulting code work through the same canonical Gitea path. Do not invent routine approvals.\n8. Treat transient runner, Gitea, network and provider failures as retryable. Escalate to human attention only for credentials, policy authorization, destructive risk or a genuine product decision. Local models such as Ollama are optional helpers when explicitly permitted; they do not change the Gitea execution contract.\n`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "repository-bootstrap",
|
name: "repository-bootstrap",
|
||||||
@@ -19,7 +19,7 @@ const definitions: SkillDefinition[] = [
|
|||||||
{
|
{
|
||||||
name: "feature-delivery",
|
name: "feature-delivery",
|
||||||
description: "Plan, implement, validate, review, and merge repository work through ProjectBrain's governed feature-delivery loop.",
|
description: "Plan, implement, validate, review, and merge repository work through ProjectBrain's governed feature-delivery loop.",
|
||||||
body: `# Feature delivery\n\n1. Load projectbrain.delivery.get when resuming; durable state, not chat history, is the source of truth.\n2. Create a complete plan. ProjectBrain auto-authorizes owner-controlled MCP delivery plans, so continue directly to start or resume the valid isolated non-default delivery branch instead of asking for routine plan approval.\n3. Apply up to 25 create/update/delete operations atomically with projectbrain.delivery.changeset.apply. Refresh the branch head and file SHAs before every changeset and retry only after re-reading a conflict.\n4. Resume through gitea.workspace.status. Dispatch the narrowest fixed validation profile with gitea.workspace.run and use full before merge when supported. Respect pollAfterMs and poll only while terminal=false; after a transient runner failure, dispatch the same managed profile again instead of using the Gitea UI. Never stream logs into the browser unless diagnosing one selected failed job.\n5. Open or update the pull request, synchronize exact-head validation evidence, and inspect gitea.change.review.\n6. When completion/merge is already part of the user's instruction or an active Auto Mode roadmap, call projectbrain.delivery.merge with the exact head SHA after validation. It is idempotent across retries, rechecks Actions evidence, reconciles ProjectBrain and can clean the merged branch.\n7. Import a compact final execution summary as durable feedback and report changed paths, validation evidence, PR URL and merge state.\n`,
|
body: `# Feature delivery\n\n1. Load projectbrain.delivery.get when resuming; durable state, not chat history, is the source of truth.\n2. Create a complete plan. ProjectBrain auto-authorizes owner-controlled MCP delivery plans, so continue directly to start or resume the valid isolated non-default delivery branch instead of asking for routine plan approval.\n3. Apply up to 25 create/update/delete operations atomically with projectbrain.delivery.changeset.apply. Refresh the branch head and file SHAs before every changeset and retry only after re-reading a conflict.\n4. Resume through gitea.workspace.status. Prefer local workspace tests while code is changing: targeted checks per behavioral slice and a broader local regression at coherent checkpoints. Do not call gitea.workspace.run after every slice or subcommit and do not push only to obtain runner evidence. Use a narrow managed profile only for a genuine remote-environment gap or an explicit independent checkpoint. When the exact HEAD is merge-ready, dispatch full once; a later code change makes that evidence stale and requires a new full only when the new HEAD is again merge-ready. Respect pollAfterMs and poll only while terminal=false. Never stream logs into the browser unless diagnosing one selected failed job.\n5. Open or update the pull request, synchronize exact-head validation evidence, and inspect gitea.change.review.\n6. When completion/merge is already part of the user's instruction or an active Auto Mode roadmap, call projectbrain.delivery.merge with the exact head SHA after validation. It is idempotent across retries, rechecks Actions evidence, reconciles ProjectBrain and can clean the merged branch.\n7. Import a compact final execution summary as durable feedback and report changed paths, validation evidence, PR URL and merge state.\n`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "portfolio-curator",
|
name: "portfolio-curator",
|
||||||
@@ -66,7 +66,7 @@ export const augmentProjectBrainInitialize = (payload: unknown) => {
|
|||||||
const extensions = capabilities.extensions && typeof capabilities.extensions === "object" ? capabilities.extensions as Record<string, unknown> : {};
|
const extensions = capabilities.extensions && typeof capabilities.extensions === "object" ? capabilities.extensions as Record<string, unknown> : {};
|
||||||
extensions[EXTENSION_ID] ??= {};
|
extensions[EXTENSION_ID] ??= {};
|
||||||
document.result.capabilities = { ...capabilities, extensions };
|
document.result.capabilities = { ...capabilities, extensions };
|
||||||
const autoInstruction = "ProjectBrain Auto Mode uses Gitea as its only primary repository execution surface. ProjectBrain remains the durable controller for roadmap, dependencies, recovery and human decisions; ChatGPT may orchestrate reasoning, but implementation changes must use governed Gitea branches, exact-head validation, pull requests and merge. For an Auto implementation item, use work.execution.branch when available or the deterministic projectbrain/auto/<normalized-work-id> branch when working directly from repository planning truth. Branch existence is the atomic claim: resume an existing branch instead of creating a duplicate. Completion is reconciled from the canonical PR, successful exact-head Gitea Actions evidence, its merge commit and downstream ProjectBrain state. heartbeat_auto_executor, claim_auto_work and report_auto_work are backward-compatible coordination/bookkeeping surfaces only; missing report_auto_work must never make a valid Gitea execution appear incomplete. Never write directly to the default branch, bypass validation, or create parallel branches for one durable work id.";
|
const autoInstruction = "ProjectBrain Auto Mode uses Gitea as its only primary repository execution surface. ProjectBrain remains the durable controller for roadmap, dependencies, recovery and human decisions; ChatGPT may orchestrate reasoning, but implementation changes must use governed Gitea branches, exact-head validation, pull requests and merge. For an Auto implementation item, use work.execution.branch when available or the deterministic projectbrain/auto/<normalized-work-id> branch when working directly from repository planning truth. Branch existence is the atomic claim: resume an existing branch instead of creating a duplicate. Completion is reconciled from the canonical PR, successful exact-head managed full validation evidence, its merge commit and downstream ProjectBrain state. Inner-loop development is local-first: managed runners are checkpoint and merge gates, not per-slice test workers. heartbeat_auto_executor, claim_auto_work and report_auto_work are backward-compatible coordination/bookkeeping surfaces only; missing report_auto_work must never make a valid Gitea execution appear incomplete. Never write directly to the default branch, bypass validation, or create parallel branches for one durable work id.";
|
||||||
document.result.instructions = document.result.instructions ? `${document.result.instructions}\n\n${autoInstruction}` : autoInstruction;
|
document.result.instructions = document.result.instructions ? `${document.result.instructions}\n\n${autoInstruction}` : autoInstruction;
|
||||||
return document;
|
return document;
|
||||||
};
|
};
|
||||||
@@ -8,123 +8,42 @@ import { getFeatureDelivery } from "./featureDeliveryRepository.js";
|
|||||||
import { createBrainstormForProject } from "./brainstormRepository.js";
|
import { createBrainstormForProject } from "./brainstormRepository.js";
|
||||||
import { parseRoadmapMarkdown } from "../services/autonomyRoadmap.js";
|
import { parseRoadmapMarkdown } from "../services/autonomyRoadmap.js";
|
||||||
import { decideRecovery } from "../services/recoveryPolicy.js";
|
import { decideRecovery } from "../services/recoveryPolicy.js";
|
||||||
|
import { applyAutonomyQueueOrder, clearAutonomyQueueOrder, nextManualOrderForAutonomyState } from "../services/autonomyQueueOrdering.js";
|
||||||
import { hasGiteaAutonomyCompletionEvidence, type AutonomyExecutionTarget } from "./autonomyExecutionEvidence.js";
|
import { hasGiteaAutonomyCompletionEvidence, type AutonomyExecutionTarget } from "./autonomyExecutionEvidence.js";
|
||||||
|
import type {
|
||||||
|
AutonomyCompletedLease,
|
||||||
|
AutonomyEvent,
|
||||||
|
AutonomyLease,
|
||||||
|
AutonomyRoadmap,
|
||||||
|
AutonomyRoadmapItem,
|
||||||
|
AutonomyRoadmapPhase,
|
||||||
|
AutonomyRun,
|
||||||
|
AutonomyState,
|
||||||
|
AutonomyStatus,
|
||||||
|
AutonomyWorkKind,
|
||||||
|
AutonomyWorkOutcome,
|
||||||
|
RoadmapItemStatus,
|
||||||
|
RoadmapPhaseStatus,
|
||||||
|
} from "./autonomyTypes.js";
|
||||||
export { parseRoadmapMarkdown };
|
export { parseRoadmapMarkdown };
|
||||||
export { autonomyRetryDelayMinutes, classifyAutonomyBlocker };
|
export { autonomyRetryDelayMinutes, classifyAutonomyBlocker };
|
||||||
export { hasGiteaAutonomyCompletionEvidence };
|
export { hasGiteaAutonomyCompletionEvidence };
|
||||||
export type { AutonomyExecutionTarget };
|
export type { AutonomyExecutionTarget };
|
||||||
export type AutonomyStatus = "off" | "waiting_roadmap" | "running" | "blocked" | "completed";
|
export type {
|
||||||
export type AutonomyWorkKind = "brainstorm" | "implement" | "validate_phase";
|
AutonomyCompletedLease,
|
||||||
export type AutonomyWorkOutcome = "success" | "failed" | "blocked";
|
AutonomyEvent,
|
||||||
export type RoadmapItemStatus = "pending" | "in_progress" | "completed" | "blocked";
|
AutonomyLease,
|
||||||
export type RoadmapPhaseStatus = "pending" | "active" | "brainstorming" | "executing" | "validating" | "completed" | "blocked";
|
AutonomyRoadmap,
|
||||||
|
AutonomyRoadmapItem,
|
||||||
export type AutonomyRoadmapItem = {
|
AutonomyRoadmapPhase,
|
||||||
id: string;
|
AutonomyRun,
|
||||||
title: string;
|
AutonomyState,
|
||||||
instruction?: string;
|
AutonomyStatus,
|
||||||
source?: "roadmap" | "user_assignment" | "brainstorm" | "recovery" | "finding" | "delivery" | "release" | "remediation" | "feature" | "bug";
|
AutonomyWorkKind,
|
||||||
createdAt?: string;
|
AutonomyWorkOutcome,
|
||||||
status: RoadmapItemStatus;
|
RoadmapItemStatus,
|
||||||
attempts: number;
|
RoadmapPhaseStatus,
|
||||||
priority?: number;
|
} from "./autonomyTypes.js";
|
||||||
scope?: "required" | "committed" | "stretch" | "future";
|
|
||||||
risk?: "low" | "normal" | "medium" | "high" | "critical";
|
|
||||||
estimatedScope?: number;
|
|
||||||
releaseBlocking?: boolean;
|
|
||||||
waitingExternal?: boolean;
|
|
||||||
touchedPaths?: string[];
|
|
||||||
domain?: string;
|
|
||||||
deliveryId?: string;
|
|
||||||
dependsOn?: string[];
|
|
||||||
lastResult?: string;
|
|
||||||
lastError?: string;
|
|
||||||
completionEvidence?: string[];
|
|
||||||
supersededBy?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AutonomyRoadmapPhase = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
objective: string;
|
|
||||||
status: RoadmapPhaseStatus;
|
|
||||||
brainstormIterations: number;
|
|
||||||
brainstormConverged: boolean;
|
|
||||||
validationFailures: number;
|
|
||||||
items: AutonomyRoadmapItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AutonomyRoadmap = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
sourceMarkdown: string;
|
|
||||||
status: "active" | "completed" | "blocked";
|
|
||||||
currentPhaseIndex: number;
|
|
||||||
phases: AutonomyRoadmapPhase[];
|
|
||||||
importedAt: string;
|
|
||||||
completedAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AutonomyLease = {
|
|
||||||
token: string;
|
|
||||||
kind: AutonomyWorkKind;
|
|
||||||
actor: string;
|
|
||||||
runId?: string;
|
|
||||||
roadmapId: string;
|
|
||||||
phaseId: string;
|
|
||||||
itemId?: string;
|
|
||||||
createdAt: string;
|
|
||||||
expiresAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AutonomyCompletedLease = {
|
|
||||||
token: string;
|
|
||||||
outcome: AutonomyWorkOutcome;
|
|
||||||
summary: string;
|
|
||||||
addedTasks: string[];
|
|
||||||
completedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AutonomyRun = {
|
|
||||||
id: string;
|
|
||||||
actor: string;
|
|
||||||
startedAt: string;
|
|
||||||
reportedWorkItems: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AutonomyEvent = {
|
|
||||||
id: string;
|
|
||||||
at: string;
|
|
||||||
type: string;
|
|
||||||
actor: string;
|
|
||||||
summary: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AutonomyState = {
|
|
||||||
version: 1;
|
|
||||||
profile: AutonomyProfile;
|
|
||||||
enabled: boolean;
|
|
||||||
status: AutonomyStatus;
|
|
||||||
autoBrainstorm: boolean;
|
|
||||||
maxBrainstormIterations: number;
|
|
||||||
maxConsecutiveFailures: number;
|
|
||||||
leaseMinutes: number;
|
|
||||||
maxWorkItemsPerRun: number;
|
|
||||||
maxRunMinutes: number;
|
|
||||||
pauseAfterCurrent: boolean;
|
|
||||||
schedulingPolicy: "balanced" | "downstream-impact";
|
|
||||||
roadmap?: AutonomyRoadmap;
|
|
||||||
activeLease?: AutonomyLease;
|
|
||||||
blocker?: { at: string; phaseId?: string; itemId?: string; reason: string; kind: "human" | "retryable"; retryAt?: string; recoveryStage?: "retry" | "cooldown" | "alternate_recovery" | "escalated"; recoveryAttempts?: number; recoveryStartedAt?: string };
|
|
||||||
recoveryStartedAt?: string;
|
|
||||||
recoveryAttempts?: number;
|
|
||||||
maxRecoveryMinutes?: number;
|
|
||||||
runs: AutonomyRun[];
|
|
||||||
completedLeases: AutonomyCompletedLease[];
|
|
||||||
events: AutonomyEvent[];
|
|
||||||
updatedAt: string;
|
|
||||||
updatedBy: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AutonomyWork = {
|
export type AutonomyWork = {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -220,6 +139,7 @@ const normalizeItem = (value: unknown): AutonomyRoadmapItem | undefined => {
|
|||||||
...(asString(row.createdAt) ? { createdAt: asString(row.createdAt) } : {}),
|
...(asString(row.createdAt) ? { createdAt: asString(row.createdAt) } : {}),
|
||||||
...(asString(row.deliveryId) ? { deliveryId: asString(row.deliveryId) } : {}),
|
...(asString(row.deliveryId) ? { deliveryId: asString(row.deliveryId) } : {}),
|
||||||
...(typeof row.priority === "number" ? { priority: asInt(row.priority, 50, 0, 100) } : {}),
|
...(typeof row.priority === "number" ? { priority: asInt(row.priority, 50, 0, 100) } : {}),
|
||||||
|
...(typeof row.manualOrder === "number" ? { manualOrder: asInt(row.manualOrder, 0, 0, 10_000) } : {}),
|
||||||
...(["required", "committed", "stretch", "future"].includes(asString(row.scope)) ? { scope: asString(row.scope) as AutonomyRoadmapItem["scope"] } : {}),
|
...(["required", "committed", "stretch", "future"].includes(asString(row.scope)) ? { scope: asString(row.scope) as AutonomyRoadmapItem["scope"] } : {}),
|
||||||
...(["low", "normal", "medium", "high", "critical"].includes(asString(row.risk)) ? { risk: asString(row.risk) as AutonomyRoadmapItem["risk"] } : {}),
|
...(["low", "normal", "medium", "high", "critical"].includes(asString(row.risk)) ? { risk: asString(row.risk) as AutonomyRoadmapItem["risk"] } : {}),
|
||||||
...(typeof row.estimatedScope === "number" ? { estimatedScope: asInt(row.estimatedScope, 0, 0, 10) } : {}),
|
...(typeof row.estimatedScope === "number" ? { estimatedScope: asInt(row.estimatedScope, 0, 0, 10) } : {}),
|
||||||
@@ -430,6 +350,7 @@ const assignmentTitle = (input: EnqueueAutonomyTaskInput) => {
|
|||||||
export const enqueueAutonomyTaskInState = (current: AutonomyState, input: EnqueueAutonomyTaskInput, actor = "owner") => {
|
export const enqueueAutonomyTaskInState = (current: AutonomyState, input: EnqueueAutonomyTaskInput, actor = "owner") => {
|
||||||
const next = structuredClone(current) as AutonomyState;
|
const next = structuredClone(current) as AutonomyState;
|
||||||
const createdAt = nowIso();
|
const createdAt = nowIso();
|
||||||
|
const nextManualOrder = nextManualOrderForAutonomyState(current);
|
||||||
const task: AutonomyRoadmapItem = {
|
const task: AutonomyRoadmapItem = {
|
||||||
id: `assignment-${randomUUID()}`,
|
id: `assignment-${randomUUID()}`,
|
||||||
title: assignmentTitle(input),
|
title: assignmentTitle(input),
|
||||||
@@ -438,6 +359,7 @@ export const enqueueAutonomyTaskInState = (current: AutonomyState, input: Enqueu
|
|||||||
createdAt,
|
createdAt,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
|
...(nextManualOrder !== undefined ? { manualOrder: nextManualOrder } : {}),
|
||||||
};
|
};
|
||||||
const createAssignmentPhase = (): AutonomyRoadmapPhase => ({
|
const createAssignmentPhase = (): AutonomyRoadmapPhase => ({
|
||||||
id: `projectbrain-opdrachten-${randomUUID()}`,
|
id: `projectbrain-opdrachten-${randomUUID()}`,
|
||||||
@@ -480,6 +402,28 @@ export const enqueueAutonomyTaskInState = (current: AutonomyState, input: Enqueu
|
|||||||
return { state, task };
|
return { state, task };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const reorderAutonomyQueueInState = (current: AutonomyState, orderedItemIds: string[], actor = "owner") => {
|
||||||
|
const next = applyAutonomyQueueOrder(current, orderedItemIds);
|
||||||
|
return appendEvent(next, event("autonomy.queue.reordered", actor, `Reordered ${orderedItemIds.length} pending Auto Mode work item(s).`));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reorderAutonomyQueue = async (projectIdOrSlug: string, orderedItemIds: string[], actor = "owner") => {
|
||||||
|
const project = await getProjectRow(projectIdOrSlug);
|
||||||
|
const current = retryAutonomyBlockerIfDue(expireLeaseIfNeeded(readState(project, actor)));
|
||||||
|
return saveState(project, reorderAutonomyQueueInState(current, orderedItemIds, actor), actor);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetAutonomyQueueOrderInState = (current: AutonomyState, actor = "owner") => {
|
||||||
|
const next = clearAutonomyQueueOrder(current);
|
||||||
|
return appendEvent(next, event("autonomy.queue.order_reset", actor, "Restored automatic Auto Mode scheduling order."));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetAutonomyQueueOrder = async (projectIdOrSlug: string, actor = "owner") => {
|
||||||
|
const project = await getProjectRow(projectIdOrSlug);
|
||||||
|
const current = retryAutonomyBlockerIfDue(expireLeaseIfNeeded(readState(project, actor)));
|
||||||
|
return saveState(project, resetAutonomyQueueOrderInState(current, actor), actor);
|
||||||
|
};
|
||||||
|
|
||||||
const expireLeaseIfNeeded = (state: AutonomyState) => {
|
const expireLeaseIfNeeded = (state: AutonomyState) => {
|
||||||
if (!state.activeLease || new Date(state.activeLease.expiresAt).getTime() > Date.now()) return state;
|
if (!state.activeLease || new Date(state.activeLease.expiresAt).getTime() > Date.now()) return state;
|
||||||
const next = structuredClone(state) as AutonomyState;
|
const next = structuredClone(state) as AutonomyState;
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { AutonomyProfile } from "@projectbrain/contracts";
|
||||||
|
|
||||||
|
export type AutonomyStatus = "off" | "waiting_roadmap" | "running" | "blocked" | "completed";
|
||||||
|
export type AutonomyWorkKind = "brainstorm" | "implement" | "validate_phase";
|
||||||
|
export type AutonomyWorkOutcome = "success" | "failed" | "blocked";
|
||||||
|
export type RoadmapItemStatus = "pending" | "in_progress" | "completed" | "blocked";
|
||||||
|
export type RoadmapPhaseStatus = "pending" | "active" | "brainstorming" | "executing" | "validating" | "completed" | "blocked";
|
||||||
|
|
||||||
|
export type AutonomyRoadmapItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
instruction?: string;
|
||||||
|
source?: "roadmap" | "user_assignment" | "brainstorm" | "recovery" | "finding" | "delivery" | "release" | "remediation" | "feature" | "bug";
|
||||||
|
createdAt?: string;
|
||||||
|
status: RoadmapItemStatus;
|
||||||
|
attempts: number;
|
||||||
|
priority?: number;
|
||||||
|
manualOrder?: number;
|
||||||
|
scope?: "required" | "committed" | "stretch" | "future";
|
||||||
|
risk?: "low" | "normal" | "medium" | "high" | "critical";
|
||||||
|
estimatedScope?: number;
|
||||||
|
releaseBlocking?: boolean;
|
||||||
|
waitingExternal?: boolean;
|
||||||
|
touchedPaths?: string[];
|
||||||
|
domain?: string;
|
||||||
|
deliveryId?: string;
|
||||||
|
dependsOn?: string[];
|
||||||
|
lastResult?: string;
|
||||||
|
lastError?: string;
|
||||||
|
completionEvidence?: string[];
|
||||||
|
supersededBy?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutonomyRoadmapPhase = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
objective: string;
|
||||||
|
status: RoadmapPhaseStatus;
|
||||||
|
brainstormIterations: number;
|
||||||
|
brainstormConverged: boolean;
|
||||||
|
validationFailures: number;
|
||||||
|
items: AutonomyRoadmapItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutonomyRoadmap = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
sourceMarkdown: string;
|
||||||
|
status: "active" | "completed" | "blocked";
|
||||||
|
currentPhaseIndex: number;
|
||||||
|
phases: AutonomyRoadmapPhase[];
|
||||||
|
importedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutonomyLease = {
|
||||||
|
token: string;
|
||||||
|
kind: AutonomyWorkKind;
|
||||||
|
actor: string;
|
||||||
|
runId?: string;
|
||||||
|
roadmapId: string;
|
||||||
|
phaseId: string;
|
||||||
|
itemId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutonomyCompletedLease = {
|
||||||
|
token: string;
|
||||||
|
outcome: AutonomyWorkOutcome;
|
||||||
|
summary: string;
|
||||||
|
addedTasks: string[];
|
||||||
|
completedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutonomyRun = {
|
||||||
|
id: string;
|
||||||
|
actor: string;
|
||||||
|
startedAt: string;
|
||||||
|
reportedWorkItems: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutonomyEvent = {
|
||||||
|
id: string;
|
||||||
|
at: string;
|
||||||
|
type: string;
|
||||||
|
actor: string;
|
||||||
|
summary: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutonomyState = {
|
||||||
|
version: 1;
|
||||||
|
profile: AutonomyProfile;
|
||||||
|
enabled: boolean;
|
||||||
|
status: AutonomyStatus;
|
||||||
|
autoBrainstorm: boolean;
|
||||||
|
maxBrainstormIterations: number;
|
||||||
|
maxConsecutiveFailures: number;
|
||||||
|
leaseMinutes: number;
|
||||||
|
maxWorkItemsPerRun: number;
|
||||||
|
maxRunMinutes: number;
|
||||||
|
pauseAfterCurrent: boolean;
|
||||||
|
schedulingPolicy: "balanced" | "downstream-impact";
|
||||||
|
roadmap?: AutonomyRoadmap;
|
||||||
|
activeLease?: AutonomyLease;
|
||||||
|
blocker?: { at: string; phaseId?: string; itemId?: string; reason: string; kind: "human" | "retryable"; retryAt?: string; recoveryStage?: "retry" | "cooldown" | "alternate_recovery" | "escalated"; recoveryAttempts?: number; recoveryStartedAt?: string };
|
||||||
|
recoveryStartedAt?: string;
|
||||||
|
recoveryAttempts?: number;
|
||||||
|
maxRecoveryMinutes?: number;
|
||||||
|
runs: AutonomyRun[];
|
||||||
|
completedLeases: AutonomyCompletedLease[];
|
||||||
|
events: AutonomyEvent[];
|
||||||
|
updatedAt: string;
|
||||||
|
updatedBy: string;
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import { prisma } from "../db/prisma.js";
|
|||||||
import { getRequestId } from "../plugins/requestId.js";
|
import { getRequestId } from "../plugins/requestId.js";
|
||||||
import { createGiteaActivityRuntimeLoader, isActiveRunnerJob, prioritizeRuntimeJobs, type CompactRunnerJob } from "../services/activityRuntime.js";
|
import { createGiteaActivityRuntimeLoader, isActiveRunnerJob, prioritizeRuntimeJobs, type CompactRunnerJob } from "../services/activityRuntime.js";
|
||||||
import { loadAutonomyRuntime } from "../services/autonomyRuntime.js";
|
import { loadAutonomyRuntime } from "../services/autonomyRuntime.js";
|
||||||
|
import { evaluateRunnerClaimHealth } from "../services/runnerClaimHealth.js";
|
||||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||||
import { apiPaths } from "@projectbrain/contracts";
|
import { apiPaths } from "@projectbrain/contracts";
|
||||||
|
|
||||||
@@ -105,8 +106,10 @@ export const registerActivityRoutes = async (app: FastifyInstance, options: { en
|
|||||||
prisma.analysisWorkerHeartbeat.findFirst({ orderBy: { lastSeenAt: "desc" } }),
|
prisma.analysisWorkerHeartbeat.findFirst({ orderBy: { lastSeenAt: "desc" } }),
|
||||||
loadAutonomyRuntime(options.env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS),
|
loadAutonomyRuntime(options.env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS),
|
||||||
]);
|
]);
|
||||||
|
const claimHealth = evaluateRunnerClaimHealth(snapshot, { stallSeconds: options.env.PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS });
|
||||||
return successEnvelope({
|
return successEnvelope({
|
||||||
...snapshot,
|
...snapshot,
|
||||||
|
claimHealth,
|
||||||
worker: worker ? { status: Date.now() - worker.lastSeenAt.getTime() < 30_000 ? (worker.activeJobId ? "working" : worker.status) : "stale", activeJobId: worker.activeJobId ?? undefined, detail: worker.detail ?? undefined, lastSeenAt: worker.lastSeenAt.toISOString() } : undefined,
|
worker: worker ? { status: Date.now() - worker.lastSeenAt.getTime() < 30_000 ? (worker.activeJobId ? "working" : worker.status) : "stale", activeJobId: worker.activeJobId ?? undefined, detail: worker.detail ?? undefined, lastSeenAt: worker.lastSeenAt.toISOString() } : undefined,
|
||||||
autonomy,
|
autonomy,
|
||||||
pollAfterMs: snapshot.counts.running || snapshot.counts.runnable || snapshot.counts.dependencyBlocked || autonomy.counts.activeProjects || autonomy.counts.claimableProjects ? 8_000 : 30_000,
|
pollAfterMs: snapshot.counts.running || snapshot.counts.runnable || snapshot.counts.dependencyBlocked || autonomy.counts.activeProjects || autonomy.counts.claimableProjects ? 8_000 : 30_000,
|
||||||
@@ -138,6 +141,7 @@ export const registerActivityRoutes = async (app: FastifyInstance, options: { en
|
|||||||
prisma.analysisWorkerHeartbeat.findFirst({ orderBy: { lastSeenAt: "desc" } }),
|
prisma.analysisWorkerHeartbeat.findFirst({ orderBy: { lastSeenAt: "desc" } }),
|
||||||
loadAutonomyRuntime(options.env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS),
|
loadAutonomyRuntime(options.env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS),
|
||||||
]);
|
]);
|
||||||
|
const claimHealth = evaluateRunnerClaimHealth(giteaRuntime, { stallSeconds: options.env.PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS });
|
||||||
const projectAutonomy = autonomy.projects.find((item) => item.projectId === project.id);
|
const projectAutonomy = autonomy.projects.find((item) => item.projectId === project.id);
|
||||||
const repositoryNames = new Set(project.giteaRepositories.map((link) => `${link.owner}/${link.repository}`.toLowerCase()));
|
const repositoryNames = new Set(project.giteaRepositories.map((link) => `${link.owner}/${link.repository}`.toLowerCase()));
|
||||||
const projectJobs = giteaRuntime.jobs.filter((job) => job.repository && repositoryNames.has(job.repository.toLowerCase())).slice(0, 20);
|
const projectJobs = giteaRuntime.jobs.filter((job) => job.repository && repositoryNames.has(job.repository.toLowerCase())).slice(0, 20);
|
||||||
@@ -176,6 +180,7 @@ export const registerActivityRoutes = async (app: FastifyInstance, options: { en
|
|||||||
const stateVersion = createHash("sha256").update(JSON.stringify({
|
const stateVersion = createHash("sha256").update(JSON.stringify({
|
||||||
items: items.map((item) => [item.id, item.status, item.createdAt]),
|
items: items.map((item) => [item.id, item.status, item.createdAt]),
|
||||||
gitea: giteaRuntime.stateVersion,
|
gitea: giteaRuntime.stateVersion,
|
||||||
|
runnerClaimHealth: [claimHealth.status, claimHealth.coverage.complete, claimHealth.coverage.observed, claimHealth.coverage.total, claimHealth.runnerInventoryComplete, claimHealth.counts.pollingStalled, claimHealth.counts.labelBlocked, claimHealth.counts.unknown, claimHealth.oldestWaitSeconds, claimHealth.oldestClaimableWaitSeconds],
|
||||||
worker: workerSnapshot ? [workerSnapshot.status, workerSnapshot.activeJobId, workerSnapshot.detail] : undefined,
|
worker: workerSnapshot ? [workerSnapshot.status, workerSnapshot.activeJobId, workerSnapshot.detail] : undefined,
|
||||||
autonomy: projectAutonomy ? [projectAutonomy.status, projectAutonomy.pendingWorkCount, projectAutonomy.activeWorkKind, projectAutonomy.blockerKind, projectAutonomy.blockerReason, autonomy.executor?.status] : undefined,
|
autonomy: projectAutonomy ? [projectAutonomy.status, projectAutonomy.pendingWorkCount, projectAutonomy.activeWorkKind, projectAutonomy.blockerKind, projectAutonomy.blockerReason, autonomy.executor?.status] : undefined,
|
||||||
})).digest("hex").slice(0, 24);
|
})).digest("hex").slice(0, 24);
|
||||||
@@ -194,6 +199,7 @@ export const registerActivityRoutes = async (app: FastifyInstance, options: { en
|
|||||||
activeJobCount: giteaRuntime.counts.running,
|
activeJobCount: giteaRuntime.counts.running,
|
||||||
queuedJobCount: giteaRuntime.counts.runnable,
|
queuedJobCount: giteaRuntime.counts.runnable,
|
||||||
dependencyBlockedJobCount: giteaRuntime.counts.dependencyBlocked,
|
dependencyBlockedJobCount: giteaRuntime.counts.dependencyBlocked,
|
||||||
|
claimHealth,
|
||||||
worker: workerSnapshot,
|
worker: workerSnapshot,
|
||||||
autonomy: { executor: autonomy.executor, executors: autonomy.executors, project: projectAutonomy },
|
autonomy: { executor: autonomy.executor, executors: autonomy.executors, project: projectAutonomy },
|
||||||
generatedAt: giteaRuntime.generatedAt,
|
generatedAt: giteaRuntime.generatedAt,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autonomyProfileSchema } from "@projectbrain/contracts";
|
import { autonomyProfileSchema } from "@projectbrain/contracts";
|
||||||
import type { AppEnv } from "../config/env.js";
|
import type { AppEnv } from "../config/env.js";
|
||||||
import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, reportAutonomyWork, resumeAutonomy } from "../repositories/autonomyRepository.js";
|
import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, reorderAutonomyQueue, reportAutonomyWork, resetAutonomyQueueOrder, resumeAutonomy } from "../repositories/autonomyRepository.js";
|
||||||
import { enqueueAutonomyTask } from "../services/autonomyAssignments.js";
|
import { enqueueAutonomyTask } from "../services/autonomyAssignments.js";
|
||||||
import { claimAutonomyWork } from "../services/autonomyRuntimeControl.js";
|
import { claimAutonomyWork } from "../services/autonomyRuntimeControl.js";
|
||||||
import { reconcileRepositoryAutonomyRoadmap } from "../services/repositoryAutonomy.js";
|
import { reconcileRepositoryAutonomyRoadmap } from "../services/repositoryAutonomy.js";
|
||||||
@@ -28,6 +28,7 @@ const configSchema = z.object({
|
|||||||
}).refine((value) => Object.keys(value).length > 0, "At least one Auto Mode setting is required.");
|
}).refine((value) => Object.keys(value).length > 0, "At least one Auto Mode setting is required.");
|
||||||
const roadmapSchema = z.object({ title: z.string().trim().min(2).max(200).optional(), markdown: z.string().trim().min(3).max(100_000) });
|
const roadmapSchema = z.object({ title: z.string().trim().min(2).max(200).optional(), markdown: z.string().trim().min(3).max(100_000) });
|
||||||
const taskSchema = z.object({ title: z.string().trim().min(2).max(200).optional(), instruction: z.string().trim().min(3).max(1_200) });
|
const taskSchema = z.object({ title: z.string().trim().min(2).max(200).optional(), instruction: z.string().trim().min(3).max(1_200) });
|
||||||
|
const queueOrderSchema = z.object({ itemIds: z.array(idSchema).max(500) });
|
||||||
const claimSchema = z.object({
|
const claimSchema = z.object({
|
||||||
runId: z.string().trim().min(1).max(160).optional(),
|
runId: z.string().trim().min(1).max(160).optional(),
|
||||||
}).default({});
|
}).default({});
|
||||||
@@ -70,7 +71,7 @@ const actorFor = (request: FastifyRequest, fallback = "owner") => request.authUs
|
|||||||
|
|
||||||
const sendAutonomyError = (request: FastifyRequest, reply: FastifyReply, error: unknown) => {
|
const sendAutonomyError = (request: FastifyRequest, reply: FastifyReply, error: unknown) => {
|
||||||
const code = error instanceof Error ? error.message : "AUTO_MODE_OPERATION_FAILED";
|
const code = error instanceof Error ? error.message : "AUTO_MODE_OPERATION_FAILED";
|
||||||
const conflictCodes = ["AUTO_MODE_DISABLED", "AUTO_MODE_ROADMAP_REQUIRED", "AUTO_MODE_LEASE_STALE", "AUTO_MODE_LEASE_OWNER_MISMATCH", "AUTO_MODE_ROADMAP_CHANGED", "AUTO_MODE_CONFLICT", "AUTO_MODE_DELIVERY_REQUIRED", "AUTO_MODE_DELIVERY_NOT_MERGED", "AUTO_MODE_DELIVERY_ALREADY_LINKED"];
|
const conflictCodes = ["AUTO_MODE_DISABLED", "AUTO_MODE_ROADMAP_REQUIRED", "AUTO_MODE_LEASE_STALE", "AUTO_MODE_LEASE_OWNER_MISMATCH", "AUTO_MODE_ROADMAP_CHANGED", "AUTO_MODE_QUEUE_CHANGED", "AUTO_MODE_CONFLICT", "AUTO_MODE_DELIVERY_REQUIRED", "AUTO_MODE_DELIVERY_NOT_MERGED", "AUTO_MODE_DELIVERY_ALREADY_LINKED"];
|
||||||
const status = code === "PROJECT_NOT_FOUND" ? 404 : code === "AUTO_MODE_WORK_ACTIVE" || conflictCodes.includes(code) ? 409 : 400;
|
const status = code === "PROJECT_NOT_FOUND" ? 404 : code === "AUTO_MODE_WORK_ACTIVE" || conflictCodes.includes(code) ? 409 : 400;
|
||||||
const messages: Record<string, string> = {
|
const messages: Record<string, string> = {
|
||||||
PROJECT_NOT_FOUND: "Project not found.",
|
PROJECT_NOT_FOUND: "Project not found.",
|
||||||
@@ -80,6 +81,7 @@ const sendAutonomyError = (request: FastifyRequest, reply: FastifyReply, error:
|
|||||||
AUTO_MODE_LEASE_STALE: "This autonomous work lease is no longer active. Claim the current work item again.",
|
AUTO_MODE_LEASE_STALE: "This autonomous work lease is no longer active. Claim the current work item again.",
|
||||||
AUTO_MODE_LEASE_OWNER_MISMATCH: "This autonomous work lease belongs to a different authenticated executor.",
|
AUTO_MODE_LEASE_OWNER_MISMATCH: "This autonomous work lease belongs to a different authenticated executor.",
|
||||||
AUTO_MODE_ROADMAP_CHANGED: "The roadmap changed while this work item was active. Claim a fresh work item.",
|
AUTO_MODE_ROADMAP_CHANGED: "The roadmap changed while this work item was active. Claim a fresh work item.",
|
||||||
|
AUTO_MODE_QUEUE_CHANGED: "De wachtrij is intussen gewijzigd. Herlaad de actuele volgorde en probeer opnieuw.",
|
||||||
AUTO_MODE_CONFLICT: "Auto Mode state changed concurrently. Reload the state and retry the operation.",
|
AUTO_MODE_CONFLICT: "Auto Mode state changed concurrently. Reload the state and retry the operation.",
|
||||||
AUTO_MODE_DELIVERY_NOT_MERGED: "Implementation is unfinished. Complete exact-head validation and merge on the canonical Gitea execution branch before reporting success.",
|
AUTO_MODE_DELIVERY_NOT_MERGED: "Implementation is unfinished. Complete exact-head validation and merge on the canonical Gitea execution branch before reporting success.",
|
||||||
AUTO_MODE_DELIVERY_REQUIRED: "Implementation success requires reconciled Gitea validated-merge evidence or a backward-compatible ProjectBrain delivery record.",
|
AUTO_MODE_DELIVERY_REQUIRED: "Implementation success requires reconciled Gitea validated-merge evidence or a backward-compatible ProjectBrain delivery record.",
|
||||||
@@ -130,6 +132,21 @@ export const registerAutonomyRoutes = async (app: FastifyInstance, options: { da
|
|||||||
catch (error) { return sendAutonomyError(request, reply, error); }
|
catch (error) { return sendAutonomyError(request, reply, error); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put("/api/projects/:projectId/autonomy/queue", async (request, reply) => {
|
||||||
|
const parsed = params(request, reply); if (!parsed) return reply;
|
||||||
|
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||||
|
const body = queueOrderSchema.safeParse(request.body ?? {}); if (!body.success) return sendValidationError(request, reply, body.error.issues);
|
||||||
|
try { return successEnvelope(await reorderAutonomyQueue(parsed.projectId, body.data.itemIds, actorFor(request)), getRequestId(request)); }
|
||||||
|
catch (error) { return sendAutonomyError(request, reply, error); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/projects/:projectId/autonomy/queue", async (request, reply) => {
|
||||||
|
const parsed = params(request, reply); if (!parsed) return reply;
|
||||||
|
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||||
|
try { return successEnvelope(await resetAutonomyQueueOrder(parsed.projectId, actorFor(request)), getRequestId(request)); }
|
||||||
|
catch (error) { return sendAutonomyError(request, reply, error); }
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/projects/:projectId/autonomy/roadmap", async (request, reply) => {
|
app.post("/api/projects/:projectId/autonomy/roadmap", async (request, reply) => {
|
||||||
const parsed = params(request, reply); if (!parsed) return reply;
|
const parsed = params(request, reply); if (!parsed) return reply;
|
||||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { z } from "zod";
|
|||||||
import type { AppEnv } from "../config/env.js";
|
import type { AppEnv } from "../config/env.js";
|
||||||
import { prisma } from "../db/prisma.js";
|
import { prisma } from "../db/prisma.js";
|
||||||
import { GiteaApiError } from "../integrations/gitea/client.js";
|
import { GiteaApiError } from "../integrations/gitea/client.js";
|
||||||
import { assertExpectedFileSha, assertFileSize, assertProjectBrainBranch, assertReadablePath, assertSuccessfulActionRuns, assertWritablePath, findReusableDeliveryPullRequest } from "../integrations/gitea/policy.js";
|
import { assertExpectedFileSha, assertFileSize, assertProjectBrainBranch, assertReadablePath, assertSuccessfulActionRuns, assertWritablePath, findReusableDeliveryPullRequest, successfulFullActionRun } from "../integrations/gitea/policy.js";
|
||||||
import { finishExternalOperation, getGiteaClient, getLinkedRepository, GiteaRepositoryOwnershipConflictError, GiteaRepositoryTransferBlockedError, linkRepository, listLinkedRepositories, listProjectRepositories, markExternalOperationExecuting, prepareExternalOperation, recordMcpAudit, unlinkRepository, unlinkRepositoryById, type LinkedRepository } from "../integrations/gitea/service.js";
|
import { finishExternalOperation, getGiteaClient, getLinkedRepository, GiteaRepositoryOwnershipConflictError, GiteaRepositoryTransferBlockedError, linkRepository, listLinkedRepositories, listProjectRepositories, markExternalOperationExecuting, prepareExternalOperation, recordMcpAudit, unlinkRepository, unlinkRepositoryById, type LinkedRepository } from "../integrations/gitea/service.js";
|
||||||
import type { GiteaContent } from "../integrations/gitea/types.js";
|
import type { GiteaContent } from "../integrations/gitea/types.js";
|
||||||
import { assertFeatureDeliveryActionEvidence, getFeatureDelivery, openFeatureDeliveryPullRequest, reconcileFeatureDeliveryPullRequest, recordFeatureDeliveryActionValidation, recordFeatureDeliveryProgress, startFeatureDelivery } from "../repositories/featureDeliveryRepository.js";
|
import { assertFeatureDeliveryActionEvidence, getFeatureDelivery, openFeatureDeliveryPullRequest, reconcileFeatureDeliveryPullRequest, recordFeatureDeliveryActionValidation, recordFeatureDeliveryProgress, startFeatureDelivery } from "../repositories/featureDeliveryRepository.js";
|
||||||
@@ -463,8 +463,8 @@ export const registerGiteaIntegrationRoutes = async (app: FastifyInstance, optio
|
|||||||
const link = await requireWorkbenchLink(projectId, false, delivery.spec.repositoryId); const client = getGiteaClient(env);
|
const link = await requireWorkbenchLink(projectId, false, delivery.spec.repositoryId); const client = getGiteaClient(env);
|
||||||
const branch = assertProjectBrainBranch(delivery.spec.branch, link.defaultBranch); const head = await client.getBranch(link.owner, link.repository, branch);
|
const branch = assertProjectBrainBranch(delivery.spec.branch, link.defaultBranch); const head = await client.getBranch(link.owner, link.repository, branch);
|
||||||
const response = await client.listActionRuns(link.owner, link.repository, { branch, headSha: head.commit.id, limit: 30 });
|
const response = await client.listActionRuns(link.owner, link.repository, { branch, headSha: head.commit.id, limit: 30 });
|
||||||
const runs = assertSuccessfulActionRuns(response.workflow_runs, branch, head.commit.id);
|
assertSuccessfulActionRuns(response.workflow_runs, branch, head.commit.id);
|
||||||
const run = runs.reduce((latest, candidate) => candidate.run_number > latest.run_number ? candidate : latest);
|
const run = successfulFullActionRun(response.workflow_runs, branch, head.commit.id);
|
||||||
const evidence = { provider: "gitea_actions" as const, runId: run.id, runNumber: run.run_number, url: run.html_url, workflowPath: run.path, headSha: run.head_sha, conclusion: "success" as const, verifiedAt: new Date().toISOString() };
|
const evidence = { provider: "gitea_actions" as const, runId: run.id, runNumber: run.run_number, url: run.html_url, workflowPath: run.path, headSha: run.head_sha, conclusion: "success" as const, verifiedAt: new Date().toISOString() };
|
||||||
return successEnvelope({ run, delivery: await recordFeatureDeliveryActionValidation(projectId, deliveryId, evidence, request.authUser?.username ?? "owner") }, getRequestId(request));
|
return successEnvelope({ run, delivery: await recordFeatureDeliveryActionValidation(projectId, deliveryId, evidence, request.authUser?.username ?? "owner") }, getRequestId(request));
|
||||||
} catch (error) { const failure = integrationError(error); return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request))); }
|
} catch (error) { const failure = integrationError(error); return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request))); }
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const requireOwner = (request: FastifyRequest, reply: FastifyReply) => isOwnerRe
|
|||||||
|
|
||||||
const operationError = (request: FastifyRequest, reply: FastifyReply, error: unknown) => {
|
const operationError = (request: FastifyRequest, reply: FastifyReply, error: unknown) => {
|
||||||
const code = error instanceof Error ? error.message : "OPERATING_SYSTEM_OPERATION_FAILED";
|
const code = error instanceof Error ? error.message : "OPERATING_SYSTEM_OPERATION_FAILED";
|
||||||
const status = code === "PROJECT_NOT_FOUND" || code.endsWith("NOT_FOUND") ? 404 : code.includes("STALE") || code.includes("CYCLE") ? 409 : 400;
|
const status = code === "PROJECT_NOT_FOUND" || code.endsWith("NOT_FOUND") ? 404 : code.includes("STALE") || code.includes("CYCLE") || code === "WORK_GRAPH_ACTIVE_LEASE" ? 409 : 400;
|
||||||
return reply.status(status).send(errorEnvelope(code, "ProjectBrain could not complete this operating-system action.", getRequestId(request)));
|
return reply.status(status).send(errorEnvelope(code, "ProjectBrain could not complete this operating-system action.", getRequestId(request)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ import { createHash } from "node:crypto";
|
|||||||
import type { AppEnv } from "../config/env.js";
|
import type { AppEnv } from "../config/env.js";
|
||||||
import { getGiteaClient } from "../integrations/gitea/service.js";
|
import { getGiteaClient } from "../integrations/gitea/service.js";
|
||||||
import type { GiteaActionJob, GiteaActionRunner } from "../integrations/gitea/types.js";
|
import type { GiteaActionJob, GiteaActionRunner } from "../integrations/gitea/types.js";
|
||||||
|
import { hasCompatibleIdleRunner } from "./runnerCompatibility.js";
|
||||||
|
|
||||||
const RUNNING_JOB_STATUSES = new Set(["in_progress", "running"]);
|
const RUNNING_JOB_STATUSES = new Set(["in_progress", "running"]);
|
||||||
const RUNNABLE_JOB_STATUSES = new Set(["queued"]);
|
const RUNNABLE_JOB_STATUSES = new Set(["queued"]);
|
||||||
const DEPENDENCY_BLOCKED_JOB_STATUSES = new Set(["pending", "waiting"]);
|
const DEPENDENCY_BLOCKED_JOB_STATUSES = new Set(["pending", "waiting", "requested"]);
|
||||||
const ACTIVE_JOB_STATUSES = new Set([...RUNNING_JOB_STATUSES, ...RUNNABLE_JOB_STATUSES, ...DEPENDENCY_BLOCKED_JOB_STATUSES]);
|
const ACTIVE_JOB_STATUSES = new Set([...RUNNING_JOB_STATUSES, ...RUNNABLE_JOB_STATUSES, ...DEPENDENCY_BLOCKED_JOB_STATUSES]);
|
||||||
const ZERO_DATE_PREFIX = "1970-01-01";
|
const ZERO_DATE_PREFIX = "1970-01-01";
|
||||||
const FRESH_CACHE_MS = 15_000;
|
const FRESH_CACHE_MS = 15_000;
|
||||||
const ERROR_CACHE_MS = 30_000;
|
const ERROR_CACHE_MS = 30_000;
|
||||||
|
const MAX_HEALTH_SAMPLE_GAP_MS = 45_000;
|
||||||
|
const RUNNABLE_JOB_PAGE_LIMIT = 50;
|
||||||
|
const MAX_RUNNABLE_JOB_PAGES = 10;
|
||||||
|
|
||||||
export type CompactRunner = {
|
export type CompactRunner = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -39,6 +43,14 @@ export type CompactRunnerJob = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
|
runnableObservedSince?: string;
|
||||||
|
claimableObservedSince?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RunnableJobScan = {
|
||||||
|
complete: boolean;
|
||||||
|
observed: number;
|
||||||
|
total: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GiteaRuntimeSnapshot = {
|
export type GiteaRuntimeSnapshot = {
|
||||||
@@ -50,18 +62,80 @@ export type GiteaRuntimeSnapshot = {
|
|||||||
runnable: number;
|
runnable: number;
|
||||||
dependencyBlocked: number;
|
dependencyBlocked: number;
|
||||||
};
|
};
|
||||||
|
runnableJobScan: RunnableJobScan;
|
||||||
|
runnerInventoryComplete: boolean;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
stateVersion: string;
|
stateVersion: string;
|
||||||
warning?: string;
|
warning?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prioritizeRuntimeJobs = <T extends Pick<CompactRunnerJob, "id" | "queueState">>(jobs: T[]): T[] => {
|
const runtimeJobTimestamp = (job: Pick<CompactRunnerJob, "queueState" | "createdAt" | "startedAt">) => {
|
||||||
|
const value = job.queueState === "running" ? job.startedAt ?? job.createdAt : job.createdAt;
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : Number.MAX_SAFE_INTEGER;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prioritizeRuntimeJobs = <T extends Pick<CompactRunnerJob, "id" | "queueState" | "createdAt" | "startedAt">>(jobs: T[]): T[] => {
|
||||||
const rank = { running: 0, runnable: 1, "dependency-blocked": 2, completed: 3 };
|
const rank = { running: 0, runnable: 1, "dependency-blocked": 2, completed: 3 };
|
||||||
return [...jobs].sort((a, b) => rank[a.queueState] - rank[b.queueState] || b.id - a.id);
|
return [...jobs].sort((a, b) => {
|
||||||
|
const rankDifference = rank[a.queueState] - rank[b.queueState];
|
||||||
|
if (rankDifference) return rankDifference;
|
||||||
|
const timeDifference = runtimeJobTimestamp(a) - runtimeJobTimestamp(b);
|
||||||
|
if (a.queueState === "runnable") return timeDifference || a.id - b.id;
|
||||||
|
return a.queueState === "completed"
|
||||||
|
? (timeDifference ? -timeDifference : b.id - a.id)
|
||||||
|
: (timeDifference || b.id - a.id);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
type RuntimeClient = Pick<ReturnType<typeof getGiteaClient>, "listUserActionRunners" | "listUserActionJobs">;
|
type RuntimeClient = Pick<ReturnType<typeof getGiteaClient>, "listUserActionRunners" | "listUserActionJobs">;
|
||||||
|
|
||||||
|
type RunnableJobScanResult = {
|
||||||
|
jobs: GiteaActionJob[];
|
||||||
|
totalCount: number | null;
|
||||||
|
complete: boolean;
|
||||||
|
warning?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scanRunnableJobs = async (client: RuntimeClient): Promise<RunnableJobScanResult> => {
|
||||||
|
const firstPage = await client.listUserActionJobs({ status: "queued", limit: RUNNABLE_JOB_PAGE_LIMIT, page: 1 });
|
||||||
|
const totalCount = Number.isSafeInteger(firstPage.total_count) && firstPage.total_count >= 0 ? firstPage.total_count : null;
|
||||||
|
const jobs = new Map(firstPage.jobs.map((job) => [job.id, job]));
|
||||||
|
let consistent = totalCount !== null
|
||||||
|
&& firstPage.jobs.length === Math.min(totalCount, RUNNABLE_JOB_PAGE_LIMIT)
|
||||||
|
&& jobs.size === firstPage.jobs.length;
|
||||||
|
let warning: string | undefined = totalCount === null ? "Gitea returned an invalid queued-job total." : undefined;
|
||||||
|
|
||||||
|
if (totalCount !== null && totalCount > 0 && firstPage.jobs.length === 0) {
|
||||||
|
consistent = false;
|
||||||
|
warning = "Gitea returned an empty queued-job page while reporting queued jobs.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageCount = totalCount === null ? 1 : Math.min(MAX_RUNNABLE_JOB_PAGES, Math.ceil(totalCount / RUNNABLE_JOB_PAGE_LIMIT));
|
||||||
|
for (let page = 2; totalCount !== null && page <= pageCount && jobs.size < totalCount; page += 1) {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await client.listUserActionJobs({ status: "queued", limit: RUNNABLE_JOB_PAGE_LIMIT, page });
|
||||||
|
} catch (error) {
|
||||||
|
warning = error instanceof Error ? error.message : "A queued-job page could not be read.";
|
||||||
|
consistent = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const expectedPageSize = Math.min(RUNNABLE_JOB_PAGE_LIMIT, totalCount - ((page - 1) * RUNNABLE_JOB_PAGE_LIMIT));
|
||||||
|
if (response.total_count !== totalCount || response.jobs.length !== expectedPageSize) consistent = false;
|
||||||
|
for (const job of response.jobs) {
|
||||||
|
if (jobs.has(job.id)) consistent = false;
|
||||||
|
jobs.set(job.id, job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const complete = totalCount !== null
|
||||||
|
&& consistent
|
||||||
|
&& totalCount <= RUNNABLE_JOB_PAGE_LIMIT * MAX_RUNNABLE_JOB_PAGES
|
||||||
|
&& jobs.size === totalCount;
|
||||||
|
return { jobs: [...jobs.values()], totalCount, complete, ...(warning ? { warning } : {}) };
|
||||||
|
};
|
||||||
|
|
||||||
const validDate = (value?: string) => value && !value.startsWith(ZERO_DATE_PREFIX) ? value : undefined;
|
const validDate = (value?: string) => value && !value.startsWith(ZERO_DATE_PREFIX) ? value : undefined;
|
||||||
|
|
||||||
const queueStateFor = (status: string): CompactRunnerJob["queueState"] => {
|
const queueStateFor = (status: string): CompactRunnerJob["queueState"] => {
|
||||||
@@ -122,6 +196,9 @@ export const compactGiteaRuntime = (
|
|||||||
jobs: GiteaActionJob[],
|
jobs: GiteaActionJob[],
|
||||||
generatedAt: string,
|
generatedAt: string,
|
||||||
exactCounts?: GiteaRuntimeSnapshot["counts"],
|
exactCounts?: GiteaRuntimeSnapshot["counts"],
|
||||||
|
runnableJobScan?: RunnableJobScan,
|
||||||
|
runnerInventoryComplete = true,
|
||||||
|
previousSnapshot?: GiteaRuntimeSnapshot,
|
||||||
): GiteaRuntimeSnapshot => {
|
): GiteaRuntimeSnapshot => {
|
||||||
const allCompactJobs = jobs.map(compactJob);
|
const allCompactJobs = jobs.map(compactJob);
|
||||||
const compactJobs = [
|
const compactJobs = [
|
||||||
@@ -143,14 +220,60 @@ export const compactGiteaRuntime = (
|
|||||||
labels: job.labels,
|
labels: job.labels,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const compact = {
|
const counts = exactCounts ?? {
|
||||||
runners: [...runnerMap.values()].sort((left, right) => left.name.localeCompare(right.name)),
|
|
||||||
jobs: compactJobs,
|
|
||||||
counts: exactCounts ?? {
|
|
||||||
running: compactJobs.filter((job) => job.queueState === "running").length,
|
running: compactJobs.filter((job) => job.queueState === "running").length,
|
||||||
runnable: compactJobs.filter((job) => job.queueState === "runnable").length,
|
runnable: compactJobs.filter((job) => job.queueState === "runnable").length,
|
||||||
dependencyBlocked: compactJobs.filter((job) => job.queueState === "dependency-blocked").length,
|
dependencyBlocked: compactJobs.filter((job) => job.queueState === "dependency-blocked").length,
|
||||||
},
|
};
|
||||||
|
const scan = runnableJobScan ?? {
|
||||||
|
complete: true,
|
||||||
|
observed: compactJobs.filter((job) => job.queueState === "runnable").length,
|
||||||
|
total: counts.runnable,
|
||||||
|
};
|
||||||
|
const snapshotTime = Date.parse(generatedAt);
|
||||||
|
const previousTime = previousSnapshot ? Date.parse(previousSnapshot.generatedAt) : Number.NaN;
|
||||||
|
const sampleGap = snapshotTime - previousTime;
|
||||||
|
const canCarryRunnableObservation = Boolean(
|
||||||
|
scan.complete
|
||||||
|
&& previousSnapshot?.availability === "live"
|
||||||
|
&& previousSnapshot.runnableJobScan.complete
|
||||||
|
&& Number.isFinite(sampleGap)
|
||||||
|
&& sampleGap >= 0
|
||||||
|
&& sampleGap <= MAX_HEALTH_SAMPLE_GAP_MS,
|
||||||
|
);
|
||||||
|
const previousRunnableJobs = new Map(
|
||||||
|
canCarryRunnableObservation
|
||||||
|
? previousSnapshot!.jobs.filter((job) => job.queueState === "runnable").map((job) => [job.id, job])
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const jobsWithObservations = compactJobs.map((job) => {
|
||||||
|
if (job.queueState !== "runnable" || !scan.complete) return job;
|
||||||
|
const previous = previousRunnableJobs.get(job.id);
|
||||||
|
const sameRunnableJob = previous
|
||||||
|
&& previous.runId === job.runId
|
||||||
|
&& previous.createdAt === job.createdAt
|
||||||
|
&& previous.status === job.status;
|
||||||
|
const runnableObservedSince = sameRunnableJob
|
||||||
|
? previous.runnableObservedSince ?? previousSnapshot!.generatedAt
|
||||||
|
: generatedAt;
|
||||||
|
const idleCompatible = runnerInventoryComplete && hasCompatibleIdleRunner([...runnerMap.values()], job);
|
||||||
|
const claimableObservedSince = idleCompatible
|
||||||
|
? sameRunnableJob && previous.claimableObservedSince
|
||||||
|
? previous.claimableObservedSince
|
||||||
|
: generatedAt
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
...job,
|
||||||
|
runnableObservedSince,
|
||||||
|
...(claimableObservedSince ? { claimableObservedSince } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const compact = {
|
||||||
|
runners: [...runnerMap.values()].sort((left, right) => left.name.localeCompare(right.name)),
|
||||||
|
jobs: jobsWithObservations,
|
||||||
|
counts,
|
||||||
|
runnableJobScan: scan,
|
||||||
|
runnerInventoryComplete,
|
||||||
};
|
};
|
||||||
const stateVersion = createHash("sha256").update(JSON.stringify(compact)).digest("hex").slice(0, 24);
|
const stateVersion = createHash("sha256").update(JSON.stringify(compact)).digest("hex").slice(0, 24);
|
||||||
return { availability: "live", ...compact, generatedAt, stateVersion };
|
return { availability: "live", ...compact, generatedAt, stateVersion };
|
||||||
@@ -176,6 +299,8 @@ export const createGiteaActivityRuntimeLoader = (
|
|||||||
runners: [],
|
runners: [],
|
||||||
jobs: [],
|
jobs: [],
|
||||||
counts: { running: 0, runnable: 0, dependencyBlocked: 0 },
|
counts: { running: 0, runnable: 0, dependencyBlocked: 0 },
|
||||||
|
runnableJobScan: { complete: false, observed: 0, total: null },
|
||||||
|
runnerInventoryComplete: false,
|
||||||
generatedAt: checkedAt.toISOString(),
|
generatedAt: checkedAt.toISOString(),
|
||||||
stateVersion: "gitea-runtime-unavailable",
|
stateVersion: "gitea-runtime-unavailable",
|
||||||
warning: "Gitea runtime monitoring is not configured.",
|
warning: "Gitea runtime monitoring is not configured.",
|
||||||
@@ -187,7 +312,7 @@ export const createGiteaActivityRuntimeLoader = (
|
|||||||
|
|
||||||
inFlight = Promise.allSettled([
|
inFlight = Promise.allSettled([
|
||||||
client.listUserActionRunners(),
|
client.listUserActionRunners(),
|
||||||
client.listUserActionJobs({ status: "queued", limit: 50 }),
|
scanRunnableJobs(client),
|
||||||
client.listUserActionJobs({ status: "pending", limit: 50 }),
|
client.listUserActionJobs({ status: "pending", limit: 50 }),
|
||||||
client.listUserActionJobs({ status: "in_progress", limit: 50 }),
|
client.listUserActionJobs({ status: "in_progress", limit: 50 }),
|
||||||
client.listUserActionJobs({ limit: 30 }),
|
client.listUserActionJobs({ limit: 30 }),
|
||||||
@@ -212,14 +337,19 @@ export const createGiteaActivityRuntimeLoader = (
|
|||||||
const derived = compactGiteaRuntime([], jobs, checkedAt.toISOString()).counts;
|
const derived = compactGiteaRuntime([], jobs, checkedAt.toISOString()).counts;
|
||||||
const snapshot = compactGiteaRuntime(runners, jobs, checkedAt.toISOString(), {
|
const snapshot = compactGiteaRuntime(runners, jobs, checkedAt.toISOString(), {
|
||||||
running: runningResponse?.total_count ?? derived.running,
|
running: runningResponse?.total_count ?? derived.running,
|
||||||
runnable: runnableResponse?.total_count ?? derived.runnable,
|
runnable: runnableResponse?.totalCount ?? derived.runnable,
|
||||||
dependencyBlocked: dependencyResponse?.total_count ?? derived.dependencyBlocked,
|
dependencyBlocked: dependencyResponse?.total_count ?? derived.dependencyBlocked,
|
||||||
});
|
}, {
|
||||||
|
complete: runnableResponse?.complete ?? false,
|
||||||
|
observed: runnableResponse?.jobs.length ?? 0,
|
||||||
|
total: runnableResponse?.totalCount ?? null,
|
||||||
|
}, runnerResult.status === "fulfilled", cached);
|
||||||
const warnings = [runnerResult, ...jobResults]
|
const warnings = [runnerResult, ...jobResults]
|
||||||
.filter((result) => result.status === "rejected")
|
.filter((result) => result.status === "rejected")
|
||||||
.map((result) => result.status === "rejected"
|
.map((result) => result.status === "rejected"
|
||||||
? result.reason instanceof Error ? result.reason.message : String(result.reason)
|
? result.reason instanceof Error ? result.reason.message : String(result.reason)
|
||||||
: "");
|
: "");
|
||||||
|
if (runnableResponse?.warning) warnings.push(runnableResponse.warning);
|
||||||
cached = warnings.length ? { ...snapshot, warning: `Partial Gitea monitoring failure: ${[...new Set(warnings)].join("; ")}` } : snapshot;
|
cached = warnings.length ? { ...snapshot, warning: `Partial Gitea monitoring failure: ${[...new Set(warnings)].join("; ")}` } : snapshot;
|
||||||
cachedAt = checkedAt.getTime();
|
cachedAt = checkedAt.getTime();
|
||||||
return cached;
|
return cached;
|
||||||
@@ -233,6 +363,8 @@ export const createGiteaActivityRuntimeLoader = (
|
|||||||
runners: [],
|
runners: [],
|
||||||
jobs: [],
|
jobs: [],
|
||||||
counts: { running: 0, runnable: 0, dependencyBlocked: 0 },
|
counts: { running: 0, runnable: 0, dependencyBlocked: 0 },
|
||||||
|
runnableJobScan: { complete: false, observed: 0, total: null },
|
||||||
|
runnerInventoryComplete: false,
|
||||||
generatedAt: checkedAt.toISOString(),
|
generatedAt: checkedAt.toISOString(),
|
||||||
stateVersion: createHash("sha256").update(warning).digest("hex").slice(0, 24),
|
stateVersion: createHash("sha256").update(warning).digest("hex").slice(0, 24),
|
||||||
warning,
|
warning,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { AutonomyRoadmapItem, AutonomyState } from "../repositories/autonomyTypes.js";
|
||||||
|
|
||||||
|
const isManuallyReorderableItem = (item: AutonomyRoadmapItem) =>
|
||||||
|
item.status === "pending" && item.scope !== "future" && item.scope !== "stretch";
|
||||||
|
|
||||||
|
export const nextManualOrderForAutonomyState = (state: AutonomyState): number | undefined => {
|
||||||
|
const orders = state.roadmap?.phases
|
||||||
|
.flatMap((phase) => phase.items)
|
||||||
|
.flatMap((item) => typeof item.manualOrder === "number" ? [item.manualOrder] : []) ?? [];
|
||||||
|
return orders.length ? Math.max(...orders) + 1 : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyAutonomyQueueOrder = (current: AutonomyState, orderedItemIds: string[]): AutonomyState => {
|
||||||
|
if (!current.roadmap) throw new Error("AUTO_MODE_ROADMAP_REQUIRED");
|
||||||
|
const next = structuredClone(current) as AutonomyState;
|
||||||
|
const roadmap = next.roadmap;
|
||||||
|
if (!roadmap) throw new Error("AUTO_MODE_ROADMAP_REQUIRED");
|
||||||
|
const reorderable = roadmap.phases.flatMap((phase) => phase.items).filter(isManuallyReorderableItem);
|
||||||
|
const expectedIds = new Set(reorderable.map((item) => item.id));
|
||||||
|
const requestedIds = new Set(orderedItemIds);
|
||||||
|
if (orderedItemIds.length !== reorderable.length || requestedIds.size !== orderedItemIds.length
|
||||||
|
|| requestedIds.size !== expectedIds.size || orderedItemIds.some((id) => !expectedIds.has(id))) {
|
||||||
|
throw new Error("AUTO_MODE_QUEUE_CHANGED");
|
||||||
|
}
|
||||||
|
const positionById = new Map(orderedItemIds.map((id, index) => [id, index]));
|
||||||
|
for (const phase of roadmap.phases) {
|
||||||
|
for (const item of phase.items) {
|
||||||
|
if (isManuallyReorderableItem(item)) item.manualOrder = positionById.get(item.id);
|
||||||
|
else delete item.manualOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAutonomyQueueOrder = (current: AutonomyState): AutonomyState => {
|
||||||
|
if (!current.roadmap) throw new Error("AUTO_MODE_ROADMAP_REQUIRED");
|
||||||
|
const next = structuredClone(current) as AutonomyState;
|
||||||
|
const roadmap = next.roadmap;
|
||||||
|
if (!roadmap) throw new Error("AUTO_MODE_ROADMAP_REQUIRED");
|
||||||
|
for (const phase of roadmap.phases) for (const item of phase.items) delete item.manualOrder;
|
||||||
|
return next;
|
||||||
|
};
|
||||||
@@ -176,6 +176,7 @@ export const compactAutonomyState = (state: AutonomyState, options: CompactAuton
|
|||||||
title: clip(item.title, 500),
|
title: clip(item.title, 500),
|
||||||
status: item.status,
|
status: item.status,
|
||||||
attempts: item.attempts,
|
attempts: item.attempts,
|
||||||
|
manualOrder: item.manualOrder,
|
||||||
source: item.source,
|
source: item.source,
|
||||||
deliveryId: item.deliveryId,
|
deliveryId: item.deliveryId,
|
||||||
dependsOn: item.dependsOn,
|
dependsOn: item.dependsOn,
|
||||||
|
|||||||
@@ -85,6 +85,17 @@ export const scoreAutonomyItem = (
|
|||||||
- estimatedScopePenalty;
|
- estimatedScopePenalty;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const compareAutonomyCandidates = (left: AutonomyScheduleCandidate, right: AutonomyScheduleCandidate) => {
|
||||||
|
const leftManual = left.item.manualOrder;
|
||||||
|
const rightManual = right.item.manualOrder;
|
||||||
|
if (typeof leftManual === "number" || typeof rightManual === "number") {
|
||||||
|
if (typeof leftManual !== "number") return 1;
|
||||||
|
if (typeof rightManual !== "number") return -1;
|
||||||
|
if (leftManual !== rightManual) return leftManual - rightManual;
|
||||||
|
}
|
||||||
|
return right.score - left.score || right.downstreamCount - left.downstreamCount || left.phaseIndex - right.phaseIndex || left.item.id.localeCompare(right.item.id);
|
||||||
|
};
|
||||||
|
|
||||||
export const rankAutonomyItems = (
|
export const rankAutonomyItems = (
|
||||||
items: AutonomyRoadmapItem[],
|
items: AutonomyRoadmapItem[],
|
||||||
phaseIndex = 0,
|
phaseIndex = 0,
|
||||||
@@ -100,7 +111,7 @@ export const rankAutonomyItems = (
|
|||||||
downstreamCount: downstreamCountForAutonomyItem(item, items),
|
downstreamCount: downstreamCountForAutonomyItem(item, items),
|
||||||
conflictKeys: conflictKeysForAutonomyItem(item),
|
conflictKeys: conflictKeysForAutonomyItem(item),
|
||||||
}))
|
}))
|
||||||
.sort((left, right) => right.score - left.score || right.downstreamCount - left.downstreamCount || left.item.id.localeCompare(right.item.id));
|
.sort(compareAutonomyCandidates);
|
||||||
|
|
||||||
export const chooseNextAutonomyWork = (
|
export const chooseNextAutonomyWork = (
|
||||||
phases: AutonomyRoadmapPhase[],
|
phases: AutonomyRoadmapPhase[],
|
||||||
@@ -118,7 +129,7 @@ export const chooseNextAutonomyWork = (
|
|||||||
downstreamCount: downstreamCountForAutonomyItem(item, allItems),
|
downstreamCount: downstreamCountForAutonomyItem(item, allItems),
|
||||||
conflictKeys: conflictKeysForAutonomyItem(item),
|
conflictKeys: conflictKeysForAutonomyItem(item),
|
||||||
})));
|
})));
|
||||||
return candidates.sort((left, right) => right.score - left.score || right.downstreamCount - left.downstreamCount || left.phaseIndex - right.phaseIndex || left.item.id.localeCompare(right.item.id))[0];
|
return candidates.sort(compareAutonomyCandidates)[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const planParallelAutonomyWork = (candidates: AutonomyScheduleCandidate[]) => {
|
export const planParallelAutonomyWork = (candidates: AutonomyScheduleCandidate[]) => {
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import type { CompactRunner, CompactRunnerJob, GiteaRuntimeSnapshot } from "./activityRuntime.js";
|
||||||
|
import { isOnlineRunner, runnerMatchesJob } from "./runnerCompatibility.js";
|
||||||
|
|
||||||
|
export type RunnerClaimState = "claimable" | "capacity-wait" | "label-blocked" | "polling-stalled" | "unknown";
|
||||||
|
|
||||||
|
export type RunnerClaimDiagnosis = {
|
||||||
|
jobId: number;
|
||||||
|
jobName: string;
|
||||||
|
repository?: string;
|
||||||
|
waitSeconds: number;
|
||||||
|
claimableWaitSeconds: number;
|
||||||
|
state: RunnerClaimState;
|
||||||
|
requiredLabels: string[];
|
||||||
|
compatibleRunnerNames: string[];
|
||||||
|
compatibleIdleRunnerNames: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RunnerClaimHealth = {
|
||||||
|
status: "healthy" | "degraded" | "stalled" | "incomplete";
|
||||||
|
thresholdSeconds: number;
|
||||||
|
generatedAt: string;
|
||||||
|
oldestWaitSeconds: number;
|
||||||
|
oldestClaimableWaitSeconds: number;
|
||||||
|
coverage: {
|
||||||
|
complete: boolean;
|
||||||
|
observed: number;
|
||||||
|
total: number | null;
|
||||||
|
};
|
||||||
|
runnerInventoryComplete: boolean;
|
||||||
|
recoveryOwner: "appops-runner-fleet";
|
||||||
|
recoveryPolicy: "controller-owned";
|
||||||
|
counts: {
|
||||||
|
queued: number;
|
||||||
|
claimable: number;
|
||||||
|
capacityWait: number;
|
||||||
|
labelBlocked: number;
|
||||||
|
pollingStalled: number;
|
||||||
|
unknown: number;
|
||||||
|
};
|
||||||
|
jobs: RunnerClaimDiagnosis[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitSecondsFor = (job: CompactRunnerJob, now: number) => {
|
||||||
|
const observedSince = Date.parse(job.runnableObservedSince ?? "");
|
||||||
|
if (!Number.isFinite(observedSince) || observedSince <= 0 || observedSince > now) return 0;
|
||||||
|
return Math.max(0, Math.floor((now - observedSince) / 1_000));
|
||||||
|
};
|
||||||
|
|
||||||
|
const claimableWaitSecondsFor = (job: CompactRunnerJob, now: number) => {
|
||||||
|
const observedSince = Date.parse(job.claimableObservedSince ?? "");
|
||||||
|
if (!Number.isFinite(observedSince) || observedSince <= 0 || observedSince > now) return 0;
|
||||||
|
return Math.max(0, Math.floor((now - observedSince) / 1_000));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const evaluateRunnerClaimHealth = (
|
||||||
|
runtime: Pick<GiteaRuntimeSnapshot, "runners" | "jobs" | "generatedAt"> & Partial<Pick<GiteaRuntimeSnapshot, "availability" | "runnableJobScan" | "runnerInventoryComplete">>,
|
||||||
|
options: { stallSeconds?: number; now?: number } = {},
|
||||||
|
): RunnerClaimHealth => {
|
||||||
|
const requestedNow = options.now ?? Date.now();
|
||||||
|
const sampleTime = Date.parse(runtime.generatedAt);
|
||||||
|
const now = Number.isFinite(sampleTime) && sampleTime > 0 ? Math.min(requestedNow, sampleTime) : 0;
|
||||||
|
const thresholdSeconds = Math.max(30, Math.min(3_600, Math.trunc(options.stallSeconds ?? 120)));
|
||||||
|
const runners = runtime.runners.filter(isOnlineRunner);
|
||||||
|
const queued = runtime.jobs.filter((job) => job.queueState === "runnable");
|
||||||
|
const runnerInventoryComplete = runtime.runnerInventoryComplete !== false
|
||||||
|
&& runtime.availability !== "stale"
|
||||||
|
&& runtime.availability !== "unavailable";
|
||||||
|
const coverage = runtime.runnableJobScan
|
||||||
|
? {
|
||||||
|
complete: runtime.availability !== "stale" && runtime.availability !== "unavailable" && runtime.runnableJobScan.complete,
|
||||||
|
observed: runtime.runnableJobScan.observed,
|
||||||
|
total: runtime.runnableJobScan.total,
|
||||||
|
}
|
||||||
|
: { complete: true, observed: queued.length, total: queued.length };
|
||||||
|
|
||||||
|
const diagnoses = queued.map((job): RunnerClaimDiagnosis => {
|
||||||
|
if (!runnerInventoryComplete) {
|
||||||
|
return {
|
||||||
|
jobId: job.id,
|
||||||
|
jobName: job.name,
|
||||||
|
...(job.repository ? { repository: job.repository } : {}),
|
||||||
|
waitSeconds: waitSecondsFor(job, now),
|
||||||
|
claimableWaitSeconds: 0,
|
||||||
|
state: "unknown",
|
||||||
|
requiredLabels: [...job.labels],
|
||||||
|
compatibleRunnerNames: [],
|
||||||
|
compatibleIdleRunnerNames: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const compatible = runners.filter((runner) => runnerMatchesJob(runner, job));
|
||||||
|
const idleCompatible = compatible.filter((runner) => !runner.busy);
|
||||||
|
const waitSeconds = waitSecondsFor(job, now);
|
||||||
|
const claimableWaitSeconds = idleCompatible.length ? claimableWaitSecondsFor(job, now) : 0;
|
||||||
|
const state: RunnerClaimState = !compatible.length
|
||||||
|
? "label-blocked"
|
||||||
|
: !idleCompatible.length
|
||||||
|
? "capacity-wait"
|
||||||
|
: coverage.complete && claimableWaitSeconds >= thresholdSeconds
|
||||||
|
? "polling-stalled"
|
||||||
|
: "claimable";
|
||||||
|
return {
|
||||||
|
jobId: job.id,
|
||||||
|
jobName: job.name,
|
||||||
|
...(job.repository ? { repository: job.repository } : {}),
|
||||||
|
waitSeconds,
|
||||||
|
claimableWaitSeconds,
|
||||||
|
state,
|
||||||
|
requiredLabels: [...job.labels],
|
||||||
|
compatibleRunnerNames: compatible.map((runner) => runner.name),
|
||||||
|
compatibleIdleRunnerNames: idleCompatible.map((runner) => runner.name),
|
||||||
|
};
|
||||||
|
}).sort((left, right) => right.waitSeconds - left.waitSeconds || left.jobId - right.jobId);
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
queued: diagnoses.length,
|
||||||
|
claimable: diagnoses.filter((entry) => entry.state === "claimable").length,
|
||||||
|
capacityWait: diagnoses.filter((entry) => entry.state === "capacity-wait").length,
|
||||||
|
labelBlocked: diagnoses.filter((entry) => entry.state === "label-blocked").length,
|
||||||
|
pollingStalled: diagnoses.filter((entry) => entry.state === "polling-stalled").length,
|
||||||
|
unknown: diagnoses.filter((entry) => entry.state === "unknown").length,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
status: !coverage.complete || !runnerInventoryComplete
|
||||||
|
? "incomplete"
|
||||||
|
: counts.pollingStalled ? "stalled" : counts.labelBlocked ? "degraded" : "healthy",
|
||||||
|
thresholdSeconds,
|
||||||
|
generatedAt: runtime.generatedAt,
|
||||||
|
oldestWaitSeconds: diagnoses.reduce((oldest, entry) => Math.max(oldest, entry.waitSeconds), 0),
|
||||||
|
oldestClaimableWaitSeconds: diagnoses.reduce((oldest, entry) => Math.max(oldest, entry.claimableWaitSeconds), 0),
|
||||||
|
coverage,
|
||||||
|
runnerInventoryComplete,
|
||||||
|
recoveryOwner: "appops-runner-fleet",
|
||||||
|
recoveryPolicy: "controller-owned",
|
||||||
|
counts,
|
||||||
|
jobs: diagnoses.slice(0, 50),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
type RunnerCompatibilityShape = { disabled: boolean; status: string; busy: boolean; labels: string[] };
|
||||||
|
type JobLabelShape = { labels: string[] };
|
||||||
|
|
||||||
|
export const isOnlineRunner = (runner: RunnerCompatibilityShape) =>
|
||||||
|
!runner.disabled && (runner.status === "online" || runner.status === "observed");
|
||||||
|
|
||||||
|
const normalizeLabel = (value: string) => value.trim().toLowerCase();
|
||||||
|
|
||||||
|
export const runnerMatchesJob = (runner: RunnerCompatibilityShape, job: JobLabelShape) => {
|
||||||
|
const required = job.labels.map(normalizeLabel).filter(Boolean);
|
||||||
|
if (!required.length) return true;
|
||||||
|
const available = new Set(runner.labels.map(normalizeLabel).filter(Boolean));
|
||||||
|
return required.every((label) => available.has(label));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasCompatibleIdleRunner = (runners: RunnerCompatibilityShape[], job: JobLabelShape) =>
|
||||||
|
runners.some((runner) => isOnlineRunner(runner) && !runner.busy && runnerMatchesJob(runner, job));
|
||||||
@@ -256,8 +256,19 @@ const reviewProjection = (project: { reviewQueueItems: Array<{ id: string; title
|
|||||||
metadataJson: json({ source: "review_queue" }),
|
metadataJson: json({ source: "review_queue" }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const findProject = async (projectIdOrSlug: string) => {
|
type WorkGraphClient = Prisma.TransactionClient | typeof prisma;
|
||||||
|
|
||||||
|
const resolveProjectId = async (projectIdOrSlug: string) => {
|
||||||
const project = await prisma.project.findFirst({
|
const project = await prisma.project.findFirst({
|
||||||
|
where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!project) throw new Error("PROJECT_NOT_FOUND");
|
||||||
|
return project.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findProject = async (projectIdOrSlug: string, client: WorkGraphClient = prisma) => {
|
||||||
|
const project = await client.project.findFirst({
|
||||||
where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] },
|
where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -275,10 +286,10 @@ const findProject = async (projectIdOrSlug: string) => {
|
|||||||
return project;
|
return project;
|
||||||
};
|
};
|
||||||
|
|
||||||
const upsertProjection = async (projectId: string, projection: Projection) => {
|
const upsertProjection = async (client: Prisma.TransactionClient, projectId: string, projection: Projection, now: Date) => {
|
||||||
const { supersededByStableKey: _supersededByStableKey, ...persistedProjection } = projection;
|
const { supersededByStableKey: _supersededByStableKey, ...persistedProjection } = projection;
|
||||||
const existing = await prisma.workGraphNode.findUnique({ where: { projectId_stableKey: { projectId, stableKey: projection.stableKey } }, select: { owner: true, leaseExpiresAt: true, executionEligibility: true, completedAt: true, domainLifecycle: true, scope: true } });
|
const existing = await client.workGraphNode.findUnique({ where: { projectId_stableKey: { projectId, stableKey: projection.stableKey } }, select: { owner: true, leaseExpiresAt: true, executionEligibility: true, completedAt: true, domainLifecycle: true, scope: true } });
|
||||||
const hasLiveLease = Boolean(existing?.owner && existing.leaseExpiresAt && existing.leaseExpiresAt.getTime() > Date.now() && existing.executionEligibility === "running");
|
const hasLiveLease = Boolean(existing?.owner && existing.leaseExpiresAt && existing.leaseExpiresAt.getTime() > now.getTime() && existing.executionEligibility === "running");
|
||||||
// A completed graph node is durable evidence, but it must not hide an
|
// A completed graph node is durable evidence, but it must not hide an
|
||||||
// authoritative source reopening the work. Preserve completion only while
|
// authoritative source reopening the work. Preserve completion only while
|
||||||
// the source lifecycle still describes the same completed reality.
|
// the source lifecycle still describes the same completed reality.
|
||||||
@@ -295,35 +306,42 @@ const upsertProjection = async (projectId: string, projection: Projection) => {
|
|||||||
&& ["ready", "running"].includes(existing?.executionEligibility ?? "")
|
&& ["ready", "running"].includes(existing?.executionEligibility ?? "")
|
||||||
&& ["ready", "running"].includes(projection.executionEligibility);
|
&& ["ready", "running"].includes(projection.executionEligibility);
|
||||||
const preserveOperationalState = hasLiveLease || hasDurableCompletion || sameActiveFamily;
|
const preserveOperationalState = hasLiveLease || hasDurableCompletion || sameActiveFamily;
|
||||||
return prisma.workGraphNode.upsert({
|
return client.workGraphNode.upsert({
|
||||||
where: { projectId_stableKey: { projectId, stableKey: projection.stableKey } },
|
where: { projectId_stableKey: { projectId, stableKey: projection.stableKey } },
|
||||||
create: { projectId, ...persistedProjection, ...(persistedProjection.metadataJson ? { metadataJson: persistedProjection.metadataJson } : {}), ...(persistedProjection.completionEvidenceJson ? { completionEvidenceJson: persistedProjection.completionEvidenceJson } : {}), ...(persistedProjection.executionEligibility === "done" ? { completedAt: new Date() } : {}) },
|
create: { projectId, ...persistedProjection, ...(persistedProjection.metadataJson ? { metadataJson: persistedProjection.metadataJson } : {}), ...(persistedProjection.completionEvidenceJson ? { completionEvidenceJson: persistedProjection.completionEvidenceJson } : {}), ...(persistedProjection.executionEligibility === "done" ? { completedAt: new Date() } : {}) },
|
||||||
update: { source: persistedProjection.source, sourceId: persistedProjection.sourceId, type: persistedProjection.type, title: persistedProjection.title, domainLifecycle: persistedProjection.domainLifecycle, priority: persistedProjection.priority, phase: persistedProjection.phase ?? null, risk: persistedProjection.risk, ...(preserveOperationalState ? { scope: persistedProjection.scope } : { canonicalLifecycle: persistedProjection.canonicalLifecycle, scope: persistedProjection.scope, blockingStatus: persistedProjection.blockingStatus, executionEligibility: persistedProjection.executionEligibility, blockReason: persistedProjection.blockReason ?? null }), ...(persistedProjection.metadataJson ? { metadataJson: persistedProjection.metadataJson } : {}), ...(persistedProjection.completionEvidenceJson ? { completionEvidenceJson: persistedProjection.completionEvidenceJson } : {}), ...(persistedProjection.executionEligibility === "done" && !existing?.completedAt ? { completedAt: new Date() } : {}) },
|
update: { source: persistedProjection.source, sourceId: persistedProjection.sourceId, type: persistedProjection.type, title: persistedProjection.title, domainLifecycle: persistedProjection.domainLifecycle, priority: persistedProjection.priority, phase: persistedProjection.phase ?? null, risk: persistedProjection.risk, ...(preserveOperationalState ? { scope: persistedProjection.scope } : { canonicalLifecycle: persistedProjection.canonicalLifecycle, scope: persistedProjection.scope, blockingStatus: persistedProjection.blockingStatus, executionEligibility: persistedProjection.executionEligibility, blockReason: persistedProjection.blockReason ?? null }), ...(persistedProjection.metadataJson ? { metadataJson: persistedProjection.metadataJson } : {}), ...(persistedProjection.completionEvidenceJson ? { completionEvidenceJson: persistedProjection.completionEvidenceJson } : {}), ...(persistedProjection.executionEligibility === "done" && !existing?.completedAt ? { completedAt: new Date() } : {}) },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const syncProjectWorkGraph = async (projectIdOrSlug: string, actor = "system", options: { audit?: boolean } = {}) => {
|
const lockWorkGraphProject = async (transaction: Prisma.TransactionClient, projectId: string) => {
|
||||||
const project = await findProject(projectIdOrSlug);
|
// This still serializes work-graph writers while remaining compatible with
|
||||||
|
// the KEY SHARE lock that the existing node-first completion path takes
|
||||||
|
// when it inserts audit and evidence rows referencing the Project.
|
||||||
|
const locked = await transaction.$queryRaw<Array<{ id: string }>>`SELECT "id" FROM "Project" WHERE "id" = ${projectId} AND "deletedAt" IS NULL FOR NO KEY UPDATE`;
|
||||||
|
if (locked.length !== 1) throw new Error("PROJECT_NOT_FOUND");
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconcileProjectWorkGraph = async (transaction: Prisma.TransactionClient, project: Awaited<ReturnType<typeof findProject>>, actor: string, now: Date, options: { audit?: boolean } = {}) => {
|
||||||
const autonomy = asRecord(asRecord(project.workspaceState?.notesJson).autonomy);
|
const autonomy = asRecord(asRecord(project.workspaceState?.notesJson).autonomy);
|
||||||
const autonomyRows = autonomyProjection(project.id, autonomy);
|
const autonomyRows = autonomyProjection(project.id, autonomy);
|
||||||
const projections = [...autonomyRows.rows, ...deliveryProjection(project), ...findingProjection(project), ...initiativeProjection(project), ...reviewProjection(project), ...featureProjection(project), ...brainstormProjection(project)];
|
const projections = [...autonomyRows.rows, ...deliveryProjection(project), ...findingProjection(project), ...initiativeProjection(project), ...reviewProjection(project), ...featureProjection(project), ...brainstormProjection(project)];
|
||||||
const projectionKeys = new Set(projections.map((projection) => projection.stableKey));
|
const projectionKeys = new Set(projections.map((projection) => projection.stableKey));
|
||||||
const nodes = new Map<string, { id: string; stableKey: string }>();
|
const nodes = new Map<string, { id: string; stableKey: string }>();
|
||||||
for (const projection of projections) {
|
for (const projection of projections) {
|
||||||
const node = await upsertProjection(project.id, projection);
|
const node = await upsertProjection(transaction, project.id, projection, now);
|
||||||
nodes.set(projection.stableKey, node);
|
nodes.set(projection.stableKey, node);
|
||||||
}
|
}
|
||||||
for (const projection of projections) {
|
for (const projection of projections) {
|
||||||
if (!projection.supersededByStableKey) continue;
|
if (!projection.supersededByStableKey) continue;
|
||||||
const node = nodes.get(projection.stableKey);
|
const node = nodes.get(projection.stableKey);
|
||||||
const replacement = nodes.get(projection.supersededByStableKey) ?? nodes.get(`autonomy:${projection.supersededByStableKey.replace(/^delivery:/, "")}`);
|
const replacement = nodes.get(projection.supersededByStableKey) ?? nodes.get(`autonomy:${projection.supersededByStableKey.replace(/^delivery:/, "")}`);
|
||||||
if (node && replacement && node.id !== replacement.id) await prisma.workGraphNode.update({ where: { id: node.id }, data: { supersededById: replacement.id } });
|
if (node && replacement && node.id !== replacement.id) await transaction.workGraphNode.update({ where: { id: node.id }, data: { supersededById: replacement.id } });
|
||||||
}
|
}
|
||||||
const staleNodes = await prisma.workGraphNode.findMany({ where: { projectId: project.id }, select: { id: true, stableKey: true, title: true, executionEligibility: true } });
|
const staleNodes = await transaction.workGraphNode.findMany({ where: { projectId: project.id }, select: { id: true, stableKey: true, title: true, executionEligibility: true } });
|
||||||
for (const stale of staleNodes) {
|
for (const stale of staleNodes) {
|
||||||
if (projectionKeys.has(stale.stableKey) || ["done", "superseded"].includes(stale.executionEligibility)) continue;
|
if (projectionKeys.has(stale.stableKey) || ["done", "superseded"].includes(stale.executionEligibility)) continue;
|
||||||
await prisma.workGraphNode.update({ where: { id: stale.id }, data: { owner: null, leaseToken: null, leaseExpiresAt: null, executionEligibility: "superseded", canonicalLifecycle: "superseded", blockingStatus: "closed", blockReason: "The authoritative source no longer exposes this work item; retained history is preserved as superseded." } });
|
await transaction.workGraphNode.update({ where: { id: stale.id }, data: { owner: null, leaseToken: null, leaseExpiresAt: null, executionEligibility: "superseded", canonicalLifecycle: "superseded", blockingStatus: "closed", blockReason: "The authoritative source no longer exposes this work item; retained history is preserved as superseded." } });
|
||||||
await prisma.workGraphAuditEvent.create({ data: { projectId: project.id, nodeId: stale.id, eventType: "work.superseded", actor, summary: `Superseded stale work '${stale.title}'.`, payloadJson: json({ stableKey: stale.stableKey }) } });
|
await transaction.workGraphAuditEvent.create({ data: { projectId: project.id, nodeId: stale.id, eventType: "work.superseded", actor, summary: `Superseded stale work '${stale.title}'.`, payloadJson: json({ stableKey: stale.stableKey }) } });
|
||||||
}
|
}
|
||||||
const dependencyPairs = [...autonomyRows.dependencies];
|
const dependencyPairs = [...autonomyRows.dependencies];
|
||||||
for (const delivery of project.deliveryPlans) if (delivery.initiativeId) dependencyPairs.push({ nodeKey: `delivery:${delivery.id}`, dependencyKey: `initiative:${delivery.initiativeId}`, type: "context" });
|
for (const delivery of project.deliveryPlans) if (delivery.initiativeId) dependencyPairs.push({ nodeKey: `delivery:${delivery.id}`, dependencyKey: `initiative:${delivery.initiativeId}`, type: "context" });
|
||||||
@@ -331,21 +349,37 @@ export const syncProjectWorkGraph = async (projectIdOrSlug: string, actor = "sys
|
|||||||
const investigation = finding.investigations.find((candidate) => candidate.deliveryPlanId);
|
const investigation = finding.investigations.find((candidate) => candidate.deliveryPlanId);
|
||||||
if (investigation?.deliveryPlanId) dependencyPairs.push({ nodeKey: `finding:${finding.id}`, dependencyKey: `delivery:${investigation.deliveryPlanId}`, type: "remediation" });
|
if (investigation?.deliveryPlanId) dependencyPairs.push({ nodeKey: `finding:${finding.id}`, dependencyKey: `delivery:${investigation.deliveryPlanId}`, type: "remediation" });
|
||||||
}
|
}
|
||||||
|
const liveLeaseNodes = await transaction.workGraphNode.findMany({
|
||||||
|
where: { projectId: project.id, executionEligibility: "running", owner: { not: null }, leaseExpiresAt: { gt: now } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const liveLeaseNodeIds = new Set(liveLeaseNodes.map((node) => node.id));
|
||||||
// Rebuild only derived edges. Explicit human dependency edits are durable
|
// Rebuild only derived edges. Explicit human dependency edits are durable
|
||||||
// graph data and must survive every reconciliation pass.
|
// graph data and must survive every reconciliation pass. A running lease
|
||||||
await prisma.workGraphDependency.deleteMany({ where: { projectId: project.id, createdBy: "autonomy-sync" } });
|
// keeps the dependency set it was claimed against until the lease expires.
|
||||||
|
await transaction.workGraphDependency.deleteMany({ where: { projectId: project.id, createdBy: "autonomy-sync", ...(liveLeaseNodeIds.size ? { nodeId: { notIn: [...liveLeaseNodeIds] } } : {}) } });
|
||||||
|
const derivedEdges: Prisma.WorkGraphDependencyCreateManyInput[] = [];
|
||||||
for (const pair of dependencyPairs) {
|
for (const pair of dependencyPairs) {
|
||||||
const node = nodes.get(pair.nodeKey);
|
const node = nodes.get(pair.nodeKey);
|
||||||
const dependency = nodes.get(pair.dependencyKey);
|
const dependency = nodes.get(pair.dependencyKey);
|
||||||
if (!node || !dependency || node.id === dependency.id) continue;
|
if (!node || !dependency || node.id === dependency.id || liveLeaseNodeIds.has(node.id)) continue;
|
||||||
await prisma.workGraphDependency.create({ data: { projectId: project.id, nodeId: node.id, dependsOnNodeId: dependency.id, dependencyType: pair.type, createdBy: "autonomy-sync" } }).catch((error: unknown) => {
|
derivedEdges.push({ projectId: project.id, nodeId: node.id, dependsOnNodeId: dependency.id, dependencyType: pair.type, createdBy: "autonomy-sync" });
|
||||||
if ((error as { code?: string }).code !== "P2002") throw error;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (options.audit) await prisma.workGraphAuditEvent.create({ data: { projectId: project.id, eventType: "graph.reconciled", actor, summary: `Reconciled ${projections.length} cross-domain work node(s).`, payloadJson: json({ nodeCount: projections.length, dependencyCount: dependencyPairs.length }) } });
|
if (derivedEdges.length) await transaction.workGraphDependency.createMany({ data: derivedEdges, skipDuplicates: true });
|
||||||
|
if (options.audit) await transaction.workGraphAuditEvent.create({ data: { projectId: project.id, eventType: "graph.reconciled", actor, summary: `Reconciled ${projections.length} cross-domain work node(s).`, payloadJson: json({ nodeCount: projections.length, dependencyCount: dependencyPairs.length }) } });
|
||||||
return project.id;
|
return project.id;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const syncProjectWorkGraph = async (projectIdOrSlug: string, actor = "system", options: { audit?: boolean } = {}) => {
|
||||||
|
const projectId = await resolveProjectId(projectIdOrSlug);
|
||||||
|
return prisma.$transaction(async (transaction) => {
|
||||||
|
await lockWorkGraphProject(transaction, projectId);
|
||||||
|
const now = new Date();
|
||||||
|
const currentProject = await findProject(projectId, transaction);
|
||||||
|
return reconcileProjectWorkGraph(transaction, currentProject, actor, now, options);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const nodeView = (node: WorkGraphNodeRecord): WorkGraphNodeView => ({
|
const nodeView = (node: WorkGraphNodeRecord): WorkGraphNodeView => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
projectId: node.projectId,
|
projectId: node.projectId,
|
||||||
@@ -391,18 +425,19 @@ const nodeView = (node: WorkGraphNodeRecord): WorkGraphNodeView => ({
|
|||||||
|
|
||||||
const loadNodes = async (projectId: string) => prisma.workGraphNode.findMany({ where: { projectId }, include: { dependencies: true, dependents: true, evidence: true }, orderBy: [{ executionEligibility: "asc" }, { priority: "desc" }, { updatedAt: "desc" }] });
|
const loadNodes = async (projectId: string) => prisma.workGraphNode.findMany({ where: { projectId }, include: { dependencies: true, dependents: true, evidence: true }, orderBy: [{ executionEligibility: "asc" }, { priority: "desc" }, { updatedAt: "desc" }] });
|
||||||
|
|
||||||
const deriveNodes = (rawNodes: WorkGraphNodeView[]) => rawNodes.map((node) => {
|
const deriveNodes = (rawNodes: WorkGraphNodeView[], now = new Date()) => rawNodes.map((node) => {
|
||||||
if (node.executionEligibility === "running" && (!node.leaseExpiresAt || Date.parse(node.leaseExpiresAt) <= Date.now())) {
|
const leaseExpired = node.executionEligibility === "running" && (!node.leaseExpiresAt || Date.parse(node.leaseExpiresAt) <= now.getTime());
|
||||||
const { owner: _owner, leaseExpiresAt: _leaseExpiresAt, ...reclaimable } = node;
|
const { owner: _owner, leaseExpiresAt: _leaseExpiresAt, ...expiredNode } = node;
|
||||||
return { ...reclaimable, executionEligibility: "ready" as const, canonicalLifecycle: "ready", blockReason: "Previous lease expired; work can be safely reclaimed." };
|
const candidate = leaseExpired
|
||||||
}
|
? { ...expiredNode, executionEligibility: "ready" as const, canonicalLifecycle: "ready", blockReason: "Previous lease expired; work can be safely reclaimed." }
|
||||||
if (node.executionEligibility === "done" || node.executionEligibility === "running" || node.executionEligibility === "needs_user" || node.executionEligibility === "superseded") return node;
|
: node;
|
||||||
if (node.scope === "future" || node.scope === "stretch") return { ...node, executionEligibility: "blocked" as const, blockReason: node.blockReason ?? "Outside Required + Committed scope until explicitly promoted." };
|
if (candidate.executionEligibility === "done" || candidate.executionEligibility === "running" || candidate.executionEligibility === "needs_user" || candidate.executionEligibility === "superseded") return candidate;
|
||||||
const blockedBy = node.dependencies.filter((edge) => {
|
if (candidate.scope === "future" || candidate.scope === "stretch") return { ...candidate, executionEligibility: "blocked" as const, blockReason: candidate.blockReason ?? "Outside Required + Committed scope until explicitly promoted." };
|
||||||
|
const blockedBy = candidate.dependencies.filter((edge) => {
|
||||||
const dependency = rawNodes.find((candidate) => candidate.id === edge.dependsOnNodeId);
|
const dependency = rawNodes.find((candidate) => candidate.id === edge.dependsOnNodeId);
|
||||||
return dependency && dependency.executionEligibility !== "done" && dependency.canonicalLifecycle !== "done";
|
return dependency && dependency.executionEligibility !== "done" && dependency.canonicalLifecycle !== "done";
|
||||||
});
|
});
|
||||||
return blockedBy.length ? { ...node, executionEligibility: "waiting" as const, blockingStatus: "dependency" } : node;
|
return blockedBy.length ? { ...candidate, executionEligibility: "waiting" as const, blockingStatus: "dependency" } : candidate;
|
||||||
});
|
});
|
||||||
|
|
||||||
const genericScore = (node: WorkGraphNodeView, all: WorkGraphNodeView[]) => {
|
const genericScore = (node: WorkGraphNodeView, all: WorkGraphNodeView[]) => {
|
||||||
@@ -481,21 +516,23 @@ const assertProjectNode = async (projectIdOrSlug: string, nodeId: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const claimReadyWork = async (projectIdOrSlug: string, actor: string, leaseMinutes = 30) => {
|
export const claimReadyWork = async (projectIdOrSlug: string, actor: string, leaseMinutes = 30) => {
|
||||||
const project = await findProject(projectIdOrSlug);
|
const projectId = await resolveProjectId(projectIdOrSlug);
|
||||||
await syncProjectWorkGraph(project.id, actor);
|
|
||||||
const now = new Date();
|
|
||||||
return prisma.$transaction(async (transaction) => {
|
return prisma.$transaction(async (transaction) => {
|
||||||
|
await lockWorkGraphProject(transaction, projectId);
|
||||||
|
const now = new Date();
|
||||||
|
const currentProject = await findProject(projectId, transaction);
|
||||||
|
await reconcileProjectWorkGraph(transaction, currentProject, actor, now);
|
||||||
// Load the complete project graph before ranking. Filtering out Future or
|
// Load the complete project graph before ranking. Filtering out Future or
|
||||||
// currently-owned dependency nodes here would make a dependent item look
|
// currently-owned dependency nodes here would make a dependent item look
|
||||||
// runnable even though the dependency is still unresolved.
|
// runnable even though the dependency is still unresolved.
|
||||||
const rows = await transaction.workGraphNode.findMany({ where: { projectId: project.id }, include: { dependencies: true, dependents: true, evidence: true } });
|
const rows = await transaction.workGraphNode.findMany({ where: { projectId }, include: { dependencies: true, dependents: true, evidence: true } });
|
||||||
const candidates = rankReadyWork(deriveNodes(rows.map(nodeView)));
|
const candidates = rankReadyWork(deriveNodes(rows.map(nodeView), now));
|
||||||
const chosen = candidates[0];
|
const chosen = candidates[0];
|
||||||
if (!chosen) return undefined;
|
if (!chosen) return undefined;
|
||||||
const leaseToken = randomUUID();
|
const leaseToken = randomUUID();
|
||||||
const updated = await transaction.workGraphNode.updateMany({ where: { id: chosen.id, projectId: project.id, scope: { in: ["required", "committed"] }, OR: [{ executionEligibility: "ready" }, { executionEligibility: "running", leaseExpiresAt: { lt: now } }], AND: [{ OR: [{ owner: null }, { leaseExpiresAt: { lt: now } }] }] }, data: { owner: actor, leaseToken, leaseExpiresAt: new Date(now.getTime() + Math.max(5, Math.min(120, leaseMinutes)) * 60_000), executionEligibility: "running", canonicalLifecycle: "running", blockReason: null, attemptCount: { increment: 1 }, lastMeaningfulProgressAt: now } });
|
const updated = await transaction.workGraphNode.updateMany({ where: { id: chosen.id, projectId, scope: { in: ["required", "committed"] }, OR: [{ executionEligibility: "ready" }, { executionEligibility: "running", leaseExpiresAt: { lt: now } }], AND: [{ OR: [{ owner: null }, { leaseExpiresAt: { lt: now } }] }] }, data: { owner: actor, leaseToken, leaseExpiresAt: new Date(now.getTime() + Math.max(5, Math.min(120, leaseMinutes)) * 60_000), executionEligibility: "running", canonicalLifecycle: "running", blockReason: null, attemptCount: { increment: 1 }, lastMeaningfulProgressAt: now } });
|
||||||
if (updated.count !== 1) return undefined;
|
if (updated.count !== 1) return undefined;
|
||||||
await transaction.workGraphAuditEvent.create({ data: { projectId: project.id, nodeId: chosen.id, eventType: "work.claimed", actor, summary: `Claimed '${chosen.title}'.`, payloadJson: json({ leaseToken }) } });
|
await transaction.workGraphAuditEvent.create({ data: { projectId, nodeId: chosen.id, eventType: "work.claimed", actor, summary: `Claimed '${chosen.title}'.`, payloadJson: json({ leaseToken }) } });
|
||||||
const result = await transaction.workGraphNode.findUniqueOrThrow({ where: { id: chosen.id }, include: { dependencies: true, dependents: true, evidence: true } });
|
const result = await transaction.workGraphNode.findUniqueOrThrow({ where: { id: chosen.id }, include: { dependencies: true, dependents: true, evidence: true } });
|
||||||
return { leaseToken, node: nodeView(result) };
|
return { leaseToken, node: nodeView(result) };
|
||||||
});
|
});
|
||||||
@@ -511,20 +548,21 @@ export const releaseWork = async (projectIdOrSlug: string, nodeId: string, actor
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const reclaimExpiredWork = async (projectIdOrSlug: string, actor = "system") => {
|
export const reclaimExpiredWork = async (projectIdOrSlug: string, actor = "system") => {
|
||||||
const project = await findProject(projectIdOrSlug);
|
const projectId = await resolveProjectId(projectIdOrSlug);
|
||||||
const now = new Date();
|
|
||||||
const reclaimedCount = await prisma.$transaction(async (transaction) => {
|
const reclaimedCount = await prisma.$transaction(async (transaction) => {
|
||||||
const expired = await transaction.workGraphNode.findMany({ where: { projectId: project.id, leaseExpiresAt: { lt: now }, owner: { not: null }, executionEligibility: "running" }, select: { id: true, title: true, owner: true, leaseExpiresAt: true } });
|
await lockWorkGraphProject(transaction, projectId);
|
||||||
|
const now = new Date();
|
||||||
|
const expired = await transaction.workGraphNode.findMany({ where: { projectId, leaseExpiresAt: { lt: now }, owner: { not: null }, executionEligibility: "running" }, select: { id: true, title: true, owner: true, leaseExpiresAt: true } });
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const node of expired) {
|
for (const node of expired) {
|
||||||
const updated = await transaction.workGraphNode.updateMany({ where: { id: node.id, projectId: project.id, executionEligibility: "running", leaseExpiresAt: { lt: now } }, data: { owner: null, leaseToken: null, leaseExpiresAt: null, executionEligibility: "ready", canonicalLifecycle: "ready", consecutiveFailureCount: { increment: 1 }, blockReason: "Previous lease expired without a completion report." } });
|
const updated = await transaction.workGraphNode.updateMany({ where: { id: node.id, projectId, executionEligibility: "running", leaseExpiresAt: { lt: now } }, data: { owner: null, leaseToken: null, leaseExpiresAt: null, executionEligibility: "ready", canonicalLifecycle: "ready", consecutiveFailureCount: { increment: 1 }, blockReason: "Previous lease expired without a completion report." } });
|
||||||
if (updated.count !== 1) continue;
|
if (updated.count !== 1) continue;
|
||||||
count += 1;
|
count += 1;
|
||||||
await transaction.workGraphAuditEvent.create({ data: { projectId: project.id, nodeId: node.id, eventType: "work.reclaimed", actor, summary: `Reclaimed stale work '${node.title}'.`, payloadJson: json({ previousOwner: node.owner, expiredAt: node.leaseExpiresAt?.toISOString() }) } });
|
await transaction.workGraphAuditEvent.create({ data: { projectId, nodeId: node.id, eventType: "work.reclaimed", actor, summary: `Reclaimed stale work '${node.title}'.`, payloadJson: json({ previousOwner: node.owner, expiredAt: node.leaseExpiresAt?.toISOString() }) } });
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
});
|
});
|
||||||
return { reclaimedCount, graph: await getProjectWorkGraph(project.id) };
|
return { reclaimedCount, graph: await getProjectWorkGraph(projectId) };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const recordWorkCompletion = async (projectIdOrSlug: string, nodeId: string, actor: string, leaseToken: string, input: { summary: string; evidence?: Array<{ kind: string; referenceType: string; referenceId: string; status?: string }> }) => {
|
export const recordWorkCompletion = async (projectIdOrSlug: string, nodeId: string, actor: string, leaseToken: string, input: { summary: string; evidence?: Array<{ kind: string; referenceType: string; referenceId: string; status?: string }> }) => {
|
||||||
@@ -545,10 +583,18 @@ export const recordWorkCompletion = async (projectIdOrSlug: string, nodeId: stri
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const updateWorkDependencies = async (projectIdOrSlug: string, nodeId: string, dependencyNodeIds: string[], actor: string) => {
|
export const updateWorkDependencies = async (projectIdOrSlug: string, nodeId: string, dependencyNodeIds: string[], actor: string) => {
|
||||||
const { project, node } = await assertProjectNode(projectIdOrSlug, nodeId);
|
const projectId = await resolveProjectId(projectIdOrSlug);
|
||||||
const uniqueIds = Array.from(new Set(dependencyNodeIds));
|
const uniqueIds = Array.from(new Set(dependencyNodeIds));
|
||||||
|
await prisma.$transaction(async (transaction) => {
|
||||||
|
await lockWorkGraphProject(transaction, projectId);
|
||||||
|
const node = await transaction.workGraphNode.findFirst({ where: { id: nodeId, projectId }, select: { id: true, title: true, executionEligibility: true, owner: true, leaseExpiresAt: true } });
|
||||||
|
if (!node) throw new Error("WORK_GRAPH_NODE_NOT_FOUND");
|
||||||
if (uniqueIds.includes(nodeId)) throw new Error("WORK_GRAPH_DEPENDENCY_CYCLE");
|
if (uniqueIds.includes(nodeId)) throw new Error("WORK_GRAPH_DEPENDENCY_CYCLE");
|
||||||
const allNodes = await prisma.workGraphNode.findMany({ where: { projectId: project.id }, include: { dependencies: true } });
|
const now = new Date();
|
||||||
|
const hasActiveLease = node.executionEligibility === "running"
|
||||||
|
&& Boolean(node.owner && node.leaseExpiresAt && node.leaseExpiresAt.getTime() > now.getTime());
|
||||||
|
if (hasActiveLease) throw new Error("WORK_GRAPH_ACTIVE_LEASE");
|
||||||
|
const allNodes = await transaction.workGraphNode.findMany({ where: { projectId }, include: { dependencies: true } });
|
||||||
const edges = new Map(allNodes.map((candidate) => [candidate.id, candidate.dependencies.map((edge) => edge.dependsOnNodeId)]));
|
const edges = new Map(allNodes.map((candidate) => [candidate.id, candidate.dependencies.map((edge) => edge.dependsOnNodeId)]));
|
||||||
const reaches = (from: string, target: string, seen = new Set<string>()): boolean => {
|
const reaches = (from: string, target: string, seen = new Set<string>()): boolean => {
|
||||||
if (from === target) return true;
|
if (from === target) return true;
|
||||||
@@ -559,12 +605,11 @@ export const updateWorkDependencies = async (projectIdOrSlug: string, nodeId: st
|
|||||||
for (const dependencyId of uniqueIds) {
|
for (const dependencyId of uniqueIds) {
|
||||||
if (!edges.has(dependencyId) || reaches(dependencyId, node.id)) throw new Error("WORK_GRAPH_DEPENDENCY_CYCLE");
|
if (!edges.has(dependencyId) || reaches(dependencyId, node.id)) throw new Error("WORK_GRAPH_DEPENDENCY_CYCLE");
|
||||||
}
|
}
|
||||||
await prisma.$transaction(async (transaction) => {
|
await transaction.workGraphDependency.deleteMany({ where: { projectId, nodeId: node.id } });
|
||||||
await transaction.workGraphDependency.deleteMany({ where: { projectId: project.id, nodeId: node.id } });
|
if (uniqueIds.length) await transaction.workGraphDependency.createMany({ data: uniqueIds.map((dependsOnNodeId) => ({ projectId, nodeId: node.id, dependsOnNodeId, createdBy: actor })) });
|
||||||
if (uniqueIds.length) await transaction.workGraphDependency.createMany({ data: uniqueIds.map((dependsOnNodeId) => ({ projectId: project.id, nodeId: node.id, dependsOnNodeId, createdBy: actor })) });
|
await transaction.workGraphAuditEvent.create({ data: { projectId, nodeId: node.id, eventType: "dependencies.updated", actor, summary: `Updated dependencies for '${node.title}'.`, payloadJson: json({ dependencyNodeIds: uniqueIds }) } });
|
||||||
await transaction.workGraphAuditEvent.create({ data: { projectId: project.id, nodeId: node.id, eventType: "dependencies.updated", actor, summary: `Updated dependencies for '${node.title}'.`, payloadJson: json({ dependencyNodeIds: uniqueIds }) } });
|
|
||||||
});
|
});
|
||||||
return getProjectWorkGraph(project.id);
|
return getProjectWorkGraph(projectId);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const listWorkGraphHistory = async (projectIdOrSlug: string, limit = 100) => {
|
export const listWorkGraphHistory = async (projectIdOrSlug: string, limit = 100) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { loadEnv } from "../src/config/env.js";
|
import { loadEnv } from "../src/config/env.js";
|
||||||
import type { GiteaActionJob } from "../src/integrations/gitea/types.js";
|
import type { GiteaActionJob } from "../src/integrations/gitea/types.js";
|
||||||
import { compactGiteaRuntime, createGiteaActivityRuntimeLoader, repositoryFromActionJob } from "../src/services/activityRuntime.js";
|
import { compactGiteaRuntime, createGiteaActivityRuntimeLoader, repositoryFromActionJob } from "../src/services/activityRuntime.js";
|
||||||
|
import { evaluateRunnerClaimHealth } from "../src/services/runnerClaimHealth.js";
|
||||||
|
|
||||||
const env = loadEnv({
|
const env = loadEnv({
|
||||||
NODE_ENV: "test",
|
NODE_ENV: "test",
|
||||||
@@ -44,6 +45,26 @@ describe("compact activity runtime", () => {
|
|||||||
expect(snapshot.counts).toEqual({ running: 2, runnable: 30, dependencyBlocked: 0 });
|
expect(snapshot.counts).toEqual({ running: 2, runnable: 30, dependencyBlocked: 0 });
|
||||||
expect(snapshot.jobs).toHaveLength(32);
|
expect(snapshot.jobs).toHaveLength(32);
|
||||||
});
|
});
|
||||||
|
it("shows queued jobs oldest first so the visible lane reflects waiting age", () => {
|
||||||
|
const snapshot = compactGiteaRuntime([], [
|
||||||
|
job({ id: 9, status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-08-28T19:09:00Z" }),
|
||||||
|
job({ id: 7, status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-08-28T19:03:00Z" }),
|
||||||
|
job({ id: 8, status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-08-28T19:06:00Z" }),
|
||||||
|
], "2026-08-28T19:10:00Z");
|
||||||
|
expect(snapshot.jobs.map((item) => item.id)).toEqual([7, 8, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts unknown queued timestamps last and breaks equal creation times by ascending job id", () => {
|
||||||
|
const snapshot = compactGiteaRuntime([], [
|
||||||
|
job({ id: 9, status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-08-28T19:03:00Z" }),
|
||||||
|
job({ id: 4, status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-08-28T19:03:00Z" }),
|
||||||
|
job({ id: 7, status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-08-28T19:06:00Z" }),
|
||||||
|
job({ id: 2, status: "queued", runner_id: 0, runner_name: undefined, created_at: "not-a-timestamp" }),
|
||||||
|
], "2026-08-28T19:10:00Z");
|
||||||
|
|
||||||
|
expect(snapshot.jobs.map((item) => item.id)).toEqual([4, 9, 7, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
it("extracts a repository safely from a Gitea job URL", () => {
|
it("extracts a repository safely from a Gitea job URL", () => {
|
||||||
expect(repositoryFromActionJob(job({ html_url: "https://gitea.example.com/Space%20Owner/Repo/actions/runs/1/jobs/2" }))).toBe("Space Owner/Repo");
|
expect(repositoryFromActionJob(job({ html_url: "https://gitea.example.com/Space%20Owner/Repo/actions/runs/1/jobs/2" }))).toBe("Space Owner/Repo");
|
||||||
expect(repositoryFromActionJob(job({ html_url: "not-a-url" }))).toBeUndefined();
|
expect(repositoryFromActionJob(job({ html_url: "not-a-url" }))).toBeUndefined();
|
||||||
@@ -123,6 +144,49 @@ describe("compact activity runtime", () => {
|
|||||||
expect(snapshot.jobs[0].queueState).toBe("dependency-blocked");
|
expect(snapshot.jobs[0].queueState).toBe("dependency-blocked");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps runner health unknown when runner discovery fails but queued jobs remain visible", async () => {
|
||||||
|
const queuedJob = job({ status: "queued", runner_id: 0, runner_name: undefined });
|
||||||
|
const listUserActionRunners = vi.fn().mockRejectedValue(new Error("runner inventory unavailable"));
|
||||||
|
const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => input.status === "queued"
|
||||||
|
? { total_count: 1, jobs: [queuedJob] }
|
||||||
|
: { total_count: 0, jobs: [] });
|
||||||
|
const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs } });
|
||||||
|
|
||||||
|
const snapshot = await load();
|
||||||
|
const health = evaluateRunnerClaimHealth(snapshot, { now: Date.parse("2026-09-30T00:05:00.000Z") });
|
||||||
|
|
||||||
|
expect(snapshot).toMatchObject({
|
||||||
|
availability: "live",
|
||||||
|
runnerInventoryComplete: false,
|
||||||
|
warning: "Partial Gitea monitoring failure: runner inventory unavailable",
|
||||||
|
runnableJobScan: { complete: true, observed: 1, total: 1 },
|
||||||
|
});
|
||||||
|
expect(health).toMatchObject({
|
||||||
|
status: "incomplete",
|
||||||
|
runnerInventoryComplete: false,
|
||||||
|
coverage: { complete: true, observed: 1, total: 1 },
|
||||||
|
counts: { queued: 1, unknown: 1, labelBlocked: 0, pollingStalled: 0 },
|
||||||
|
jobs: [{ state: "unknown", claimableWaitSeconds: 0, requiredLabels: ["ubuntu-latest"], compatibleRunnerNames: [], compatibleIdleRunnerNames: [] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyInventory = createGiteaActivityRuntimeLoader(env, {
|
||||||
|
client: {
|
||||||
|
listUserActionRunners: vi.fn().mockResolvedValue({ total_count: 0, runners: [] }),
|
||||||
|
listUserActionJobs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const knownEmptySnapshot = await emptyInventory();
|
||||||
|
const knownEmptyHealth = evaluateRunnerClaimHealth(knownEmptySnapshot, { now: Date.parse("2026-09-30T00:05:00.000Z") });
|
||||||
|
|
||||||
|
expect(knownEmptySnapshot.runnerInventoryComplete).toBe(true);
|
||||||
|
expect(knownEmptyHealth).toMatchObject({
|
||||||
|
status: "degraded",
|
||||||
|
runnerInventoryComplete: true,
|
||||||
|
counts: { queued: 1, unknown: 0, labelBlocked: 1 },
|
||||||
|
jobs: [{ state: "label-blocked" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("uses Gitea's documented pending filter for dependency-blocked jobs", async () => {
|
it("uses Gitea's documented pending filter for dependency-blocked jobs", async () => {
|
||||||
const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 0, runners: [] });
|
const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 0, runners: [] });
|
||||||
const listUserActionJobs = vi.fn().mockResolvedValue({ total_count: 0, jobs: [] });
|
const listUserActionJobs = vi.fn().mockResolvedValue({ total_count: 0, jobs: [] });
|
||||||
@@ -133,4 +197,361 @@ describe("compact activity runtime", () => {
|
|||||||
expect(listUserActionJobs).toHaveBeenCalledWith({ status: "pending", limit: 50 });
|
expect(listUserActionJobs).toHaveBeenCalledWith({ status: "pending", limit: 50 });
|
||||||
expect(listUserActionJobs).not.toHaveBeenCalledWith({ status: "waiting", limit: 50 });
|
expect(listUserActionJobs).not.toHaveBeenCalledWith({ status: "waiting", limit: 50 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps Gitea requested jobs returned by the pending filter visible without a claim-health alarm", async () => {
|
||||||
|
const requestedJob = job({
|
||||||
|
id: 37_222,
|
||||||
|
run_id: 21_559,
|
||||||
|
name: "quality",
|
||||||
|
status: "requested",
|
||||||
|
runner_id: 0,
|
||||||
|
runner_name: undefined,
|
||||||
|
steps: [],
|
||||||
|
created_at: "2026-10-03T02:20:00.000Z",
|
||||||
|
});
|
||||||
|
const recentCompleted = Array.from({ length: 30 }, (_, index) => job({
|
||||||
|
id: 40_000 + index,
|
||||||
|
run_id: 30_000 + index,
|
||||||
|
status: "completed",
|
||||||
|
conclusion: "success",
|
||||||
|
runner_id: 0,
|
||||||
|
runner_name: undefined,
|
||||||
|
created_at: "2026-10-03T02:21:00.000Z",
|
||||||
|
completed_at: "2026-10-03T02:22:00.000Z",
|
||||||
|
}));
|
||||||
|
const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 1, runners: [
|
||||||
|
{ id: 9, name: "validation-1", status: "online", busy: false, disabled: false, ephemeral: false, labels: [
|
||||||
|
{ id: 1, name: "ubuntu-latest", type: "custom" },
|
||||||
|
] },
|
||||||
|
] });
|
||||||
|
const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => {
|
||||||
|
if (input.status === "pending") return { total_count: 1, jobs: [requestedJob] };
|
||||||
|
if (input.status === "queued") return { total_count: 0, jobs: [] };
|
||||||
|
if (input.status === "in_progress") return { total_count: 0, jobs: [] };
|
||||||
|
return { total_count: recentCompleted.length, jobs: recentCompleted };
|
||||||
|
});
|
||||||
|
const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs } });
|
||||||
|
|
||||||
|
const snapshot = await load();
|
||||||
|
const requested = snapshot.jobs.find((entry) => entry.id === requestedJob.id);
|
||||||
|
const health = evaluateRunnerClaimHealth(snapshot, { now: Date.parse("2026-10-03T02:24:00.000Z") });
|
||||||
|
|
||||||
|
expect(listUserActionJobs).toHaveBeenCalledWith({ status: "pending", limit: 50 });
|
||||||
|
expect(requested).toMatchObject({
|
||||||
|
id: 37_222,
|
||||||
|
runId: 21_559,
|
||||||
|
status: "requested",
|
||||||
|
queueState: "dependency-blocked",
|
||||||
|
});
|
||||||
|
expect(snapshot.counts).toEqual({ running: 0, runnable: 0, dependencyBlocked: 1 });
|
||||||
|
expect(health).toMatchObject({
|
||||||
|
status: "healthy",
|
||||||
|
counts: { queued: 0, pollingStalled: 0 },
|
||||||
|
oldestWaitSeconds: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paginates the queued-job health scan so old jobs beyond page one are diagnosed", async () => {
|
||||||
|
let clock = new Date("2026-09-30T00:05:00.000Z");
|
||||||
|
const queuedJobs = [
|
||||||
|
...Array.from({ length: 50 }, (_, index) => job({
|
||||||
|
id: 1_000 - index,
|
||||||
|
status: "queued",
|
||||||
|
runner_id: 0,
|
||||||
|
runner_name: undefined,
|
||||||
|
created_at: "2026-09-30T00:04:30.000Z",
|
||||||
|
})),
|
||||||
|
...Array.from({ length: 10 }, (_, index) => job({
|
||||||
|
id: 900 - index,
|
||||||
|
status: "queued",
|
||||||
|
runner_id: 0,
|
||||||
|
runner_name: undefined,
|
||||||
|
created_at: "2026-09-30T00:00:00.000Z",
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 1, runners: [
|
||||||
|
{ id: 9, name: "validation-1", status: "online", busy: false, disabled: false, ephemeral: false, labels: [
|
||||||
|
{ id: 1, name: "ubuntu-latest", type: "custom" },
|
||||||
|
] },
|
||||||
|
] });
|
||||||
|
const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string; limit?: number; page?: number } = {}) => {
|
||||||
|
if (input.status === "queued") {
|
||||||
|
const start = ((input.page ?? 1) - 1) * (input.limit ?? 50);
|
||||||
|
return { total_count: queuedJobs.length, jobs: queuedJobs.slice(start, start + (input.limit ?? 50)) };
|
||||||
|
}
|
||||||
|
return { total_count: 0, jobs: [] };
|
||||||
|
});
|
||||||
|
const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs }, now: () => clock });
|
||||||
|
|
||||||
|
let snapshot = await load();
|
||||||
|
for (let sample = 0; sample < 4; sample += 1) {
|
||||||
|
clock = new Date(clock.getTime() + 30_000);
|
||||||
|
snapshot = await load();
|
||||||
|
}
|
||||||
|
const health = evaluateRunnerClaimHealth(snapshot, { now: clock.getTime(), stallSeconds: 120 });
|
||||||
|
|
||||||
|
expect(listUserActionJobs).toHaveBeenCalledWith({ status: "queued", limit: 50, page: 1 });
|
||||||
|
expect(listUserActionJobs).toHaveBeenCalledWith({ status: "queued", limit: 50, page: 2 });
|
||||||
|
expect(snapshot.runnableJobScan).toEqual({ complete: true, observed: 60, total: 60 });
|
||||||
|
expect(health).toMatchObject({
|
||||||
|
status: "stalled",
|
||||||
|
coverage: { complete: true, observed: 60, total: 60 },
|
||||||
|
counts: { queued: 60, pollingStalled: 60 },
|
||||||
|
oldestWaitSeconds: 120,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat an old database-created job as a long-waiting queued job when first observed", async () => {
|
||||||
|
const clock = new Date("2026-09-30T00:05:00.000Z");
|
||||||
|
const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 1, runners: [
|
||||||
|
{ id: 9, name: "validation-1", status: "online", busy: false, disabled: false, ephemeral: false, labels: [
|
||||||
|
{ id: 1, name: "ubuntu-latest", type: "custom" },
|
||||||
|
] },
|
||||||
|
] });
|
||||||
|
const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => input.status === "queued"
|
||||||
|
? { total_count: 1, jobs: [job({ status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-09-29T00:00:00.000Z" })] }
|
||||||
|
: { total_count: 0, jobs: [] });
|
||||||
|
const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs }, now: () => clock });
|
||||||
|
|
||||||
|
const snapshot = await load();
|
||||||
|
const health = evaluateRunnerClaimHealth(snapshot, { now: Date.parse("2026-09-30T04:00:00.000Z"), stallSeconds: 120 });
|
||||||
|
|
||||||
|
expect(snapshot.jobs[0]).toMatchObject({
|
||||||
|
createdAt: "2026-09-29T00:00:00.000Z",
|
||||||
|
runnableObservedSince: clock.toISOString(),
|
||||||
|
claimableObservedSince: clock.toISOString(),
|
||||||
|
});
|
||||||
|
expect(health).toMatchObject({
|
||||||
|
status: "healthy",
|
||||||
|
counts: { queued: 1, claimable: 1, pollingStalled: 0 },
|
||||||
|
oldestWaitSeconds: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts polling-stall timing only after compatible idle capacity is observed", async () => {
|
||||||
|
let clock = new Date("2026-09-30T00:00:00.000Z");
|
||||||
|
let runnerBusy = true;
|
||||||
|
const listUserActionRunners = vi.fn().mockImplementation(async () => ({ total_count: 1, runners: [
|
||||||
|
{ id: 9, name: "validation-1", status: "online", busy: runnerBusy, disabled: false, ephemeral: false, labels: [
|
||||||
|
{ id: 1, name: "ubuntu-latest", type: "custom" },
|
||||||
|
] },
|
||||||
|
] }));
|
||||||
|
const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => input.status === "queued"
|
||||||
|
? { total_count: 1, jobs: [job({ status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-09-29T00:00:00.000Z" })] }
|
||||||
|
: { total_count: 0, jobs: [] });
|
||||||
|
const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs }, now: () => clock });
|
||||||
|
|
||||||
|
let snapshot = await load();
|
||||||
|
expect(evaluateRunnerClaimHealth(snapshot, { now: clock.getTime(), stallSeconds: 120 }).jobs[0].state).toBe("capacity-wait");
|
||||||
|
for (let sample = 0; sample < 3; sample += 1) {
|
||||||
|
clock = new Date(clock.getTime() + 30_000);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
runnerBusy = false;
|
||||||
|
clock = new Date(clock.getTime() + 30_000);
|
||||||
|
snapshot = await load();
|
||||||
|
expect(snapshot.jobs[0]).toMatchObject({
|
||||||
|
runnableObservedSince: "2026-09-30T00:00:00.000Z",
|
||||||
|
claimableObservedSince: clock.toISOString(),
|
||||||
|
});
|
||||||
|
expect(evaluateRunnerClaimHealth(snapshot, { now: clock.getTime(), stallSeconds: 120 }).jobs[0].state).toBe("claimable");
|
||||||
|
|
||||||
|
for (let sample = 0; sample < 3; sample += 1) {
|
||||||
|
clock = new Date(clock.getTime() + 30_000);
|
||||||
|
snapshot = await load();
|
||||||
|
expect(evaluateRunnerClaimHealth(snapshot, { now: clock.getTime(), stallSeconds: 120 }).jobs[0].state).toBe("claimable");
|
||||||
|
}
|
||||||
|
clock = new Date(clock.getTime() + 30_000);
|
||||||
|
snapshot = await load();
|
||||||
|
const finalHealth = evaluateRunnerClaimHealth(snapshot, { now: clock.getTime(), stallSeconds: 120 });
|
||||||
|
expect(finalHealth.jobs[0]).toMatchObject({ state: "polling-stalled", waitSeconds: 240, claimableWaitSeconds: 120 });
|
||||||
|
expect(finalHealth.oldestClaimableWaitSeconds).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not age a cached health sample with wall-clock time", async () => {
|
||||||
|
let clock = new Date("2026-09-30T00:00:00.000Z");
|
||||||
|
const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 1, runners: [
|
||||||
|
{ id: 9, name: "validation-1", status: "online", busy: false, disabled: false, ephemeral: false, labels: [
|
||||||
|
{ id: 1, name: "ubuntu-latest", type: "custom" },
|
||||||
|
] },
|
||||||
|
] });
|
||||||
|
const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => input.status === "queued"
|
||||||
|
? { total_count: 1, jobs: [job({ status: "queued", runner_id: 0, runner_name: undefined })] }
|
||||||
|
: { total_count: 0, jobs: [] });
|
||||||
|
const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs }, now: () => clock });
|
||||||
|
|
||||||
|
await load();
|
||||||
|
clock = new Date(clock.getTime() + 14_000);
|
||||||
|
const cached = await load();
|
||||||
|
const health = evaluateRunnerClaimHealth(cached, { now: clock.getTime() + 3_600_000, stallSeconds: 30 });
|
||||||
|
|
||||||
|
expect(listUserActionRunners).toHaveBeenCalledTimes(1);
|
||||||
|
expect(health).toMatchObject({ status: "healthy", oldestWaitSeconds: 0, counts: { pollingStalled: 0 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets observation timing after a long gap between complete samples", async () => {
|
||||||
|
let clock = new Date("2026-09-30T00:00:00.000Z");
|
||||||
|
const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 1, runners: [
|
||||||
|
{ id: 9, name: "validation-1", status: "online", busy: false, disabled: false, ephemeral: false, labels: [
|
||||||
|
{ id: 1, name: "ubuntu-latest", type: "custom" },
|
||||||
|
] },
|
||||||
|
] });
|
||||||
|
const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => input.status === "queued"
|
||||||
|
? { total_count: 1, jobs: [job({ status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-09-29T00:00:00.000Z" })] }
|
||||||
|
: { total_count: 0, jobs: [] });
|
||||||
|
const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs }, now: () => clock });
|
||||||
|
|
||||||
|
await load();
|
||||||
|
clock = new Date(clock.getTime() + 46_000);
|
||||||
|
const snapshot = await load();
|
||||||
|
const health = evaluateRunnerClaimHealth(snapshot, { now: clock.getTime(), stallSeconds: 30 });
|
||||||
|
|
||||||
|
expect(snapshot.jobs[0].runnableObservedSince).toBe(clock.toISOString());
|
||||||
|
expect(snapshot.jobs[0].claimableObservedSince).toBe(clock.toISOString());
|
||||||
|
expect(health).toMatchObject({ status: "healthy", oldestWaitSeconds: 0, counts: { pollingStalled: 0 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets claimable timing after partial and stale samples", async () => {
|
||||||
|
let clock = new Date("2026-09-30T00:00:00.000Z");
|
||||||
|
let failSecondPage = false;
|
||||||
|
let failAllJobQueries = false;
|
||||||
|
const queuedJobs = Array.from({ length: 51 }, (_, index) => job({
|
||||||
|
id: 1_000 - index,
|
||||||
|
status: "queued",
|
||||||
|
runner_id: 0,
|
||||||
|
runner_name: undefined,
|
||||||
|
created_at: "2026-09-29T00:00:00.000Z",
|
||||||
|
}));
|
||||||
|
const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 1, runners: [
|
||||||
|
{ id: 9, name: "validation-1", status: "online", busy: false, disabled: false, ephemeral: false, labels: [
|
||||||
|
{ id: 1, name: "ubuntu-latest", type: "custom" },
|
||||||
|
] },
|
||||||
|
] });
|
||||||
|
const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string; limit?: number; page?: number } = {}) => {
|
||||||
|
if (failAllJobQueries) throw new Error("job queries unavailable");
|
||||||
|
if (input.status === "queued") {
|
||||||
|
if (input.page === 2 && failSecondPage) throw new Error("queued page unavailable");
|
||||||
|
const start = ((input.page ?? 1) - 1) * (input.limit ?? 50);
|
||||||
|
return { total_count: queuedJobs.length, jobs: queuedJobs.slice(start, start + (input.limit ?? 50)) };
|
||||||
|
}
|
||||||
|
return { total_count: 0, jobs: [] };
|
||||||
|
});
|
||||||
|
const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs }, now: () => clock });
|
||||||
|
|
||||||
|
await load();
|
||||||
|
failSecondPage = true;
|
||||||
|
clock = new Date(clock.getTime() + 30_000);
|
||||||
|
const partial = await load();
|
||||||
|
expect(partial.runnableJobScan.complete).toBe(false);
|
||||||
|
expect(evaluateRunnerClaimHealth(partial, { now: clock.getTime() }).status).toBe("incomplete");
|
||||||
|
|
||||||
|
failSecondPage = false;
|
||||||
|
clock = new Date(clock.getTime() + 15_000);
|
||||||
|
let snapshot = await load();
|
||||||
|
expect(snapshot.jobs.find((entry) => entry.id === queuedJobs[0].id)?.claimableObservedSince).toBe(clock.toISOString());
|
||||||
|
|
||||||
|
failAllJobQueries = true;
|
||||||
|
clock = new Date(clock.getTime() + 15_000);
|
||||||
|
const stale = await load();
|
||||||
|
expect(stale.availability).toBe("stale");
|
||||||
|
expect(evaluateRunnerClaimHealth(stale, { now: clock.getTime() }).jobs[0].state).toBe("unknown");
|
||||||
|
|
||||||
|
failAllJobQueries = false;
|
||||||
|
clock = new Date(clock.getTime() + 30_000);
|
||||||
|
snapshot = await load();
|
||||||
|
const health = evaluateRunnerClaimHealth(snapshot, { now: clock.getTime(), stallSeconds: 30 });
|
||||||
|
expect(snapshot.jobs.find((entry) => entry.id === queuedJobs[0].id)?.claimableObservedSince).toBe(clock.toISOString());
|
||||||
|
expect(health).toMatchObject({ status: "healthy", counts: { pollingStalled: 0 }, oldestWaitSeconds: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restarts runnable timing after a known queued-to-pending-to-queued transition", async () => {
|
||||||
|
let clock = new Date("2026-09-30T00:00:00.000Z");
|
||||||
|
let queued = true;
|
||||||
|
const queuedJob = job({ status: "queued", runner_id: 0, runner_name: undefined, created_at: "2026-09-29T00:00:00.000Z" });
|
||||||
|
const pendingJob = { ...queuedJob, status: "pending" };
|
||||||
|
const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 1, runners: [
|
||||||
|
{ id: 9, name: "validation-1", status: "online", busy: false, disabled: false, ephemeral: false, labels: [
|
||||||
|
{ id: 1, name: "ubuntu-latest", type: "custom" },
|
||||||
|
] },
|
||||||
|
] });
|
||||||
|
const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => {
|
||||||
|
if (input.status === "queued") return { total_count: queued ? 1 : 0, jobs: queued ? [queuedJob] : [] };
|
||||||
|
if (input.status === "pending" || input.status === undefined) return { total_count: queued ? 0 : 1, jobs: queued ? [] : [pendingJob] };
|
||||||
|
return { total_count: 0, jobs: [] };
|
||||||
|
});
|
||||||
|
const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs }, now: () => clock });
|
||||||
|
|
||||||
|
const first = await load();
|
||||||
|
expect(first.jobs[0].runnableObservedSince).toBe(clock.toISOString());
|
||||||
|
|
||||||
|
queued = false;
|
||||||
|
clock = new Date(clock.getTime() + 30_000);
|
||||||
|
const pending = await load();
|
||||||
|
expect(pending.jobs[0].queueState).toBe("dependency-blocked");
|
||||||
|
|
||||||
|
queued = true;
|
||||||
|
clock = new Date(clock.getTime() + 30_000);
|
||||||
|
const requeued = await load();
|
||||||
|
expect(requeued.jobs[0].runnableObservedSince).toBe(clock.toISOString());
|
||||||
|
expect(requeued.jobs[0].claimableObservedSince).toBe(clock.toISOString());
|
||||||
|
expect(evaluateRunnerClaimHealth(requeued, { now: clock.getTime(), stallSeconds: 30 }).jobs[0].state).toBe("claimable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps paginated health scans bounded and marks queues beyond the cap incomplete", async () => {
|
||||||
|
const queuedJobs = Array.from({ length: 501 }, (_, index) => job({
|
||||||
|
id: 2_000 - index,
|
||||||
|
status: "queued",
|
||||||
|
runner_id: 0,
|
||||||
|
runner_name: undefined,
|
||||||
|
}));
|
||||||
|
const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 0, runners: [] });
|
||||||
|
const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string; limit?: number; page?: number } = {}) => {
|
||||||
|
if (input.status === "queued") {
|
||||||
|
const start = ((input.page ?? 1) - 1) * (input.limit ?? 50);
|
||||||
|
return { total_count: queuedJobs.length, jobs: queuedJobs.slice(start, start + (input.limit ?? 50)) };
|
||||||
|
}
|
||||||
|
return { total_count: 0, jobs: [] };
|
||||||
|
});
|
||||||
|
const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs } });
|
||||||
|
|
||||||
|
const snapshot = await load();
|
||||||
|
|
||||||
|
expect(listUserActionJobs.mock.calls.filter(([input]) => input?.status === "queued")).toHaveLength(10);
|
||||||
|
expect(snapshot.runnableJobScan).toEqual({ complete: false, observed: 500, total: 501 });
|
||||||
|
expect(evaluateRunnerClaimHealth(snapshot).status).toBe("incomplete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat a missing queued total as an empty complete scan", async () => {
|
||||||
|
const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 0, runners: [] });
|
||||||
|
const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => input.status === "queued"
|
||||||
|
? { jobs: [] } as unknown as { total_count: number; jobs: GiteaActionJob[] }
|
||||||
|
: { total_count: 0, jobs: [] });
|
||||||
|
const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs } });
|
||||||
|
|
||||||
|
const snapshot = await load();
|
||||||
|
|
||||||
|
expect(snapshot.runnableJobScan).toEqual({ complete: false, observed: 0, total: null });
|
||||||
|
expect(snapshot.warning).toContain("invalid queued-job total");
|
||||||
|
expect(evaluateRunnerClaimHealth(snapshot).status).toBe("incomplete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the queued health scan incomplete when a later page fails", async () => {
|
||||||
|
const queuedJobs = Array.from({ length: 60 }, (_, index) => job({ id: 1_000 - index, status: "queued" }));
|
||||||
|
const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 0, runners: [] });
|
||||||
|
const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string; limit?: number; page?: number } = {}) => {
|
||||||
|
if (input.status === "queued") {
|
||||||
|
if (input.page === 2) throw new Error("page two unavailable");
|
||||||
|
return { total_count: queuedJobs.length, jobs: queuedJobs.slice(0, input.limit ?? 50) };
|
||||||
|
}
|
||||||
|
return { total_count: 0, jobs: [] };
|
||||||
|
});
|
||||||
|
const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs } });
|
||||||
|
|
||||||
|
const snapshot = await load();
|
||||||
|
|
||||||
|
expect(snapshot.runnableJobScan).toEqual({ complete: false, observed: 50, total: 60 });
|
||||||
|
expect(snapshot.warning).toContain("page two unavailable");
|
||||||
|
expect(evaluateRunnerClaimHealth(snapshot).status).toBe("incomplete");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -3,7 +3,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|||||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||||
import { loadEnv } from "../src/config/env.js";
|
import { loadEnv } from "../src/config/env.js";
|
||||||
import { compareAutoModeProjectRows, createProjectBrainMcpServer } from "../src/mcp/autonomyServer.js";
|
import { compareAutoModeProjectRows, createProjectBrainMcpServer } from "../src/mcp/autonomyServer.js";
|
||||||
import { autonomyRetryDelayMinutes, canResumeAutonomyLease, classifyAutonomyBlocker, enqueueAutonomyTaskInState, evaluateAutonomyRunBudget, isAutonomyLeaseOwner, normalizeAutonomyState, parseRoadmapMarkdown, releaseActiveAutonomyLease, retryAutonomyBlockerIfDue } from "../src/repositories/autonomyRepository.js";
|
import { autonomyRetryDelayMinutes, canResumeAutonomyLease, classifyAutonomyBlocker, enqueueAutonomyTaskInState, evaluateAutonomyRunBudget, isAutonomyLeaseOwner, normalizeAutonomyState, parseRoadmapMarkdown, releaseActiveAutonomyLease, reorderAutonomyQueueInState, resetAutonomyQueueOrderInState, retryAutonomyBlockerIfDue } from "../src/repositories/autonomyRepository.js";
|
||||||
import { autonomyHeartbeatSlotId, compactAutonomyState, resolveAutonomyHeartbeat, summarizeAutonomyProject } from "../src/services/autonomyRuntime.js";
|
import { autonomyHeartbeatSlotId, compactAutonomyState, resolveAutonomyHeartbeat, summarizeAutonomyProject } from "../src/services/autonomyRuntime.js";
|
||||||
|
|
||||||
const env = loadEnv({
|
const env = loadEnv({
|
||||||
@@ -99,6 +99,25 @@ describe("Auto Mode state safety", () => {
|
|||||||
expect(queued.state.roadmap?.phases[1].items[1].title).toBe("Lopend werk");
|
expect(queued.state.roadmap?.phases[1].items[1].title).toBe("Lopend werk");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists an exact owner-defined order for pending executable work only", () => {
|
||||||
|
const roadmap = parseRoadmapMarkdown("## Fase\n- [ ] A\n- [ ] B\n- [ ] C");
|
||||||
|
roadmap.phases[0].items[2].scope = "future";
|
||||||
|
const current = normalizeAutonomyState({ enabled: true, roadmap }, "owner");
|
||||||
|
const [a, b] = current.roadmap!.phases[0].items;
|
||||||
|
|
||||||
|
const reordered = reorderAutonomyQueueInState(current, [b.id, a.id], "owner");
|
||||||
|
|
||||||
|
expect(reordered.roadmap?.phases[0].items[0].manualOrder).toBe(1);
|
||||||
|
expect(reordered.roadmap?.phases[0].items[1].manualOrder).toBe(0);
|
||||||
|
expect(reordered.roadmap?.phases[0].items[2].manualOrder).toBeUndefined();
|
||||||
|
expect(reordered.events.at(-1)?.type).toBe("autonomy.queue.reordered");
|
||||||
|
expect(() => reorderAutonomyQueueInState(current, [a.id], "owner")).toThrow("AUTO_MODE_QUEUE_CHANGED");
|
||||||
|
|
||||||
|
const automatic = resetAutonomyQueueOrderInState(reordered, "owner");
|
||||||
|
expect(automatic.roadmap?.phases.flatMap((phase) => phase.items).every((item) => item.manualOrder === undefined)).toBe(true);
|
||||||
|
expect(automatic.events.at(-1)?.type).toBe("autonomy.queue.order_reset");
|
||||||
|
});
|
||||||
|
|
||||||
it("derives the public status from durable facts instead of stale stored copy", () => {
|
it("derives the public status from durable facts instead of stale stored copy", () => {
|
||||||
expect(normalizeAutonomyState({ enabled: true, status: "running" }).status).toBe("waiting_roadmap");
|
expect(normalizeAutonomyState({ enabled: true, status: "running" }).status).toBe("waiting_roadmap");
|
||||||
expect(normalizeAutonomyState({}).profile).toBe("normal");
|
expect(normalizeAutonomyState({}).profile).toBe("normal");
|
||||||
@@ -305,8 +324,11 @@ describe("Auto Mode MCP surface", () => {
|
|||||||
"list_auto_mode_projects",
|
"list_auto_mode_projects",
|
||||||
"heartbeat_auto_executor",
|
"heartbeat_auto_executor",
|
||||||
"get_auto_mode_state",
|
"get_auto_mode_state",
|
||||||
|
"get_runner_claim_health",
|
||||||
"configure_auto_mode",
|
"configure_auto_mode",
|
||||||
"import_auto_roadmap",
|
"import_auto_roadmap",
|
||||||
|
"reorder_auto_queue",
|
||||||
|
"reset_auto_queue_order",
|
||||||
"resume_auto_mode",
|
"resume_auto_mode",
|
||||||
"claim_auto_work",
|
"claim_auto_work",
|
||||||
"report_auto_work",
|
"report_auto_work",
|
||||||
@@ -314,6 +336,9 @@ describe("Auto Mode MCP surface", () => {
|
|||||||
expect(advertisedTools.find((tool) => tool.name === "claim_auto_work")?.inputSchema).toMatchObject({
|
expect(advertisedTools.find((tool) => tool.name === "claim_auto_work")?.inputSchema).toMatchObject({
|
||||||
required: expect.arrayContaining(["project_id", "run_id"]),
|
required: expect.arrayContaining(["project_id", "run_id"]),
|
||||||
});
|
});
|
||||||
|
expect(advertisedTools.find((tool) => tool.name === "reorder_auto_queue")?.inputSchema).toMatchObject({
|
||||||
|
required: expect.arrayContaining(["project_id", "item_ids"]),
|
||||||
|
});
|
||||||
expect(advertisedTools.find((tool) => tool.name === "configure_auto_mode")?.inputSchema).toMatchObject({
|
expect(advertisedTools.find((tool) => tool.name === "configure_auto_mode")?.inputSchema).toMatchObject({
|
||||||
properties: expect.objectContaining({ max_work_items_per_run: expect.any(Object), max_run_minutes: expect.any(Object), max_recovery_minutes: expect.any(Object), pause_after_current: expect.any(Object), scheduling_policy: expect.any(Object) }),
|
properties: expect.objectContaining({ max_work_items_per_run: expect.any(Object), max_run_minutes: expect.any(Object), max_recovery_minutes: expect.any(Object), pause_after_current: expect.any(Object), scheduling_policy: expect.any(Object) }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,6 +36,27 @@ describe("smart autonomy scheduler", () => {
|
|||||||
expect(chooseNextAutonomyWork(roadmap.phases, 0, "downstream-impact")?.item.id).toBe(a.id);
|
expect(chooseNextAutonomyWork(roadmap.phases, 0, "downstream-impact")?.item.id).toBe(a.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("honors explicit manual queue order before automatic scoring", () => {
|
||||||
|
const roadmap = parseRoadmapMarkdown("## Delivery\n- [ ] High score\n- [ ] Owner first");
|
||||||
|
const [highScore, ownerFirst] = roadmap.phases[0].items;
|
||||||
|
highScore.priority = 100;
|
||||||
|
ownerFirst.priority = 0;
|
||||||
|
highScore.manualOrder = 1;
|
||||||
|
ownerFirst.manualOrder = 0;
|
||||||
|
|
||||||
|
expect(chooseNextAutonomyWork(roadmap.phases, 0)?.item.id).toBe(ownerFirst.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never lets manual ordering bypass an unmet dependency", () => {
|
||||||
|
const roadmap = parseRoadmapMarkdown("## Delivery\n- [ ] Prerequisite\n- [ ] Manually first");
|
||||||
|
const [prerequisite, manuallyFirst] = roadmap.phases[0].items;
|
||||||
|
manuallyFirst.dependsOn = [prerequisite.id];
|
||||||
|
manuallyFirst.manualOrder = 0;
|
||||||
|
prerequisite.manualOrder = 1;
|
||||||
|
|
||||||
|
expect(chooseNextAutonomyWork(roadmap.phases, 0)?.item.id).toBe(prerequisite.id);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not claim work while its external dependency is unavailable", () => {
|
it("does not claim work while its external dependency is unavailable", () => {
|
||||||
const roadmap = parseRoadmapMarkdown("## Delivery\n- [ ] A\n- [ ] B");
|
const roadmap = parseRoadmapMarkdown("## Delivery\n- [ ] A\n- [ ] B");
|
||||||
roadmap.phases[0].items[0].waitingExternal = true;
|
roadmap.phases[0].items[0].waitingExternal = true;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "../src/db/prisma.js";
|
import { prisma } from "../src/db/prisma.js";
|
||||||
import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, reportAutonomyWork } from "../src/repositories/autonomyRepository.js";
|
import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, reportAutonomyWork } from "../src/repositories/autonomyRepository.js";
|
||||||
import { claimAutonomyWork } from "../src/services/autonomyRuntimeControl.js";
|
import { claimAutonomyWork } from "../src/services/autonomyRuntimeControl.js";
|
||||||
@@ -11,12 +13,204 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|||||||
import { createProjectBrainMcpServer } from "../src/mcp/server.js";
|
import { createProjectBrainMcpServer } from "../src/mcp/server.js";
|
||||||
import { loadEnv } from "../src/config/env.js";
|
import { loadEnv } from "../src/config/env.js";
|
||||||
import { GiteaApiError, GiteaClient } from "../src/integrations/gitea/client.js";
|
import { GiteaApiError, GiteaClient } from "../src/integrations/gitea/client.js";
|
||||||
|
import { claimReadyWork, getProjectWorkGraph, reclaimExpiredWork, recordWorkCompletion, syncProjectWorkGraph, updateWorkDependencies } from "../src/services/workGraph.js";
|
||||||
|
|
||||||
const databaseUrl = process.env.TEST_DATABASE_URL;
|
const databaseUrl = process.env.TEST_DATABASE_URL;
|
||||||
if (databaseUrl) process.env.DATABASE_URL = databaseUrl;
|
if (databaseUrl) process.env.DATABASE_URL = databaseUrl;
|
||||||
const databaseDescribe = databaseUrl ? describe : describe.skip;
|
const databaseDescribe = databaseUrl ? describe : describe.skip;
|
||||||
const slugs: string[] = [];
|
const slugs: string[] = [];
|
||||||
|
|
||||||
|
const createWorkGraphProject = async (markdown: string) => {
|
||||||
|
const slug = `workgraph-${randomUUID()}`;
|
||||||
|
slugs.push(slug);
|
||||||
|
await createProject({ id: slug, name: "Work graph concurrency proof", description: "Isolated database regression" });
|
||||||
|
await configureAutonomy(slug, { enabled: true, autoBrainstorm: false }, "database-test");
|
||||||
|
await importAutonomyRoadmap(slug, { markdown }, "database-test");
|
||||||
|
const project = await prisma.project.findUniqueOrThrow({ where: { slug } });
|
||||||
|
const graph = await getProjectWorkGraph(slug);
|
||||||
|
return { slug, project, graph };
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeByTitle = (graph: Awaited<ReturnType<typeof getProjectWorkGraph>>, title: string) => {
|
||||||
|
const node = graph.nodes.find((candidate) => candidate.title === title);
|
||||||
|
if (!node) throw new Error(`Missing work graph fixture node '${title}'.`);
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAutonomyDependencies = async (projectId: string, slug: string, title: string, dependsOnTitles: string[]) => {
|
||||||
|
const state = await getAutonomyState(slug);
|
||||||
|
const items = state.roadmap?.phases.flatMap((phase) => phase.items) ?? [];
|
||||||
|
const target = items.find((item) => item.title === title);
|
||||||
|
if (!target) throw new Error(`Missing autonomy fixture item '${title}'.`);
|
||||||
|
target.dependsOn = dependsOnTitles.map((dependencyTitle) => {
|
||||||
|
const dependency = items.find((item) => item.title === dependencyTitle);
|
||||||
|
if (!dependency) throw new Error(`Missing autonomy fixture dependency '${dependencyTitle}'.`);
|
||||||
|
return dependency.id;
|
||||||
|
});
|
||||||
|
const workspace = await prisma.workspaceState.findUniqueOrThrow({ where: { projectId }, select: { notesJson: true } });
|
||||||
|
const notes = typeof workspace.notesJson === "object" && workspace.notesJson !== null && !Array.isArray(workspace.notesJson) ? workspace.notesJson : {};
|
||||||
|
await prisma.workspaceState.update({ where: { projectId }, data: { notesJson: { ...notes, autonomy: state } as Prisma.InputJsonValue } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAutonomyPriority = async (projectId: string, slug: string, title: string, priority: number) => {
|
||||||
|
const state = await getAutonomyState(slug);
|
||||||
|
const target = state.roadmap?.phases.flatMap((phase) => phase.items).find((item) => item.title === title);
|
||||||
|
if (!target) throw new Error(`Missing autonomy fixture item '${title}'.`);
|
||||||
|
target.priority = priority;
|
||||||
|
const workspace = await prisma.workspaceState.findUniqueOrThrow({ where: { projectId }, select: { notesJson: true } });
|
||||||
|
const notes = typeof workspace.notesJson === "object" && workspace.notesJson !== null && !Array.isArray(workspace.notesJson) ? workspace.notesJson : {};
|
||||||
|
await prisma.workspaceState.update({ where: { projectId }, data: { notesJson: { ...notes, autonomy: state } as Prisma.InputJsonValue } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAutonomyRoadmapOrder = async (projectId: string, slug: string, titles: string[]) => {
|
||||||
|
const state = await getAutonomyState(slug);
|
||||||
|
const itemOrder = new Map(titles.map((title, index) => [title, index]));
|
||||||
|
const items = state.roadmap?.phases.flatMap((phase) => phase.items) ?? [];
|
||||||
|
if (items.length !== titles.length || titles.some((title) => !items.some((item) => item.title === title))) {
|
||||||
|
throw new Error("The requested roadmap order does not match the source fixture.");
|
||||||
|
}
|
||||||
|
for (const phase of state.roadmap?.phases ?? []) {
|
||||||
|
phase.items.sort((left, right) => itemOrder.get(left.title)! - itemOrder.get(right.title)!);
|
||||||
|
}
|
||||||
|
const workspace = await prisma.workspaceState.findUniqueOrThrow({ where: { projectId }, select: { notesJson: true } });
|
||||||
|
const notes = typeof workspace.notesJson === "object" && workspace.notesJson !== null && !Array.isArray(workspace.notesJson) ? workspace.notesJson : {};
|
||||||
|
await prisma.workspaceState.update({ where: { projectId }, data: { notesJson: { ...notes, autonomy: state } as Prisma.InputJsonValue } });
|
||||||
|
};
|
||||||
|
|
||||||
|
type DatabaseLockWaiter = { pid: number; query: string; blockingPids: number[]; hasProjectRowShareLock: boolean };
|
||||||
|
const readDatabaseLockWaiters = () => prisma.$queryRaw<DatabaseLockWaiter[]>`
|
||||||
|
SELECT activity.pid, activity.query, pg_blocking_pids(activity.pid) AS "blockingPids",
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM pg_locks
|
||||||
|
WHERE pg_locks.pid = activity.pid
|
||||||
|
AND pg_locks.relation = '"Project"'::regclass
|
||||||
|
AND pg_locks.mode = 'RowShareLock'
|
||||||
|
AND pg_locks.granted
|
||||||
|
) AS "hasProjectRowShareLock"
|
||||||
|
FROM pg_stat_activity AS activity
|
||||||
|
WHERE activity.datname = current_database()
|
||||||
|
AND activity.pid <> pg_backend_pid()
|
||||||
|
AND activity.state = 'active'
|
||||||
|
AND activity.wait_event_type = 'Lock'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const isProjectCoordinationLockQuery = (query: string) => {
|
||||||
|
const normalized = query.toLowerCase();
|
||||||
|
return normalized.includes("for update") || normalized.includes("for no key update");
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForDatabaseLockWaiter = async (predicate: (waiter: DatabaseLockWaiter) => boolean, timeoutMs = 2000) => {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const waiter = (await readDatabaseLockWaiters()).find(predicate);
|
||||||
|
if (waiter) return waiter;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAuditInsertBarrier = async (projectId: string, eventType: "work.completed" | "work.reclaimed") => {
|
||||||
|
const suffix = randomUUID().replaceAll("-", "");
|
||||||
|
const triggerName = `test_audit_${suffix}`;
|
||||||
|
const functionName = `${triggerName}_fn`;
|
||||||
|
const advisoryKey = [Number.parseInt(suffix.slice(0, 7), 16), Number.parseInt(suffix.slice(7, 14), 16)] as const;
|
||||||
|
let blockerPid = 0;
|
||||||
|
let releaseAdvisoryLock: () => void = () => undefined;
|
||||||
|
let acquiredAdvisoryLock!: () => void;
|
||||||
|
let functionCreated = false;
|
||||||
|
let triggerCreated = false;
|
||||||
|
let lockHolder: Promise<void> | undefined;
|
||||||
|
const locked = new Promise<void>((resolve) => { acquiredAdvisoryLock = resolve; });
|
||||||
|
const hold = new Promise<void>((resolve) => { releaseAdvisoryLock = resolve; });
|
||||||
|
const cleanup = async () => {
|
||||||
|
releaseAdvisoryLock();
|
||||||
|
if (lockHolder) await lockHolder.catch(() => undefined);
|
||||||
|
if (triggerCreated) await prisma.$executeRawUnsafe(`DROP TRIGGER IF EXISTS ${triggerName} ON "WorkGraphAuditEvent"`);
|
||||||
|
if (functionCreated) await prisma.$executeRawUnsafe(`DROP FUNCTION IF EXISTS ${functionName}()`);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE FUNCTION ${functionName}() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_advisory_xact_lock(${advisoryKey[0]}, ${advisoryKey[1]});
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
functionCreated = true;
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TRIGGER ${triggerName}
|
||||||
|
BEFORE INSERT ON "WorkGraphAuditEvent"
|
||||||
|
FOR EACH ROW WHEN (NEW."projectId" = '${projectId}' AND NEW."eventType" = '${eventType}')
|
||||||
|
EXECUTE FUNCTION ${functionName}();
|
||||||
|
`);
|
||||||
|
triggerCreated = true;
|
||||||
|
lockHolder = prisma.$transaction(async (transaction) => {
|
||||||
|
const [backend] = await transaction.$queryRaw<Array<{ pid: number }>>`SELECT pg_backend_pid() AS pid`;
|
||||||
|
blockerPid = backend!.pid;
|
||||||
|
await transaction.$queryRaw`SELECT pg_advisory_xact_lock(${advisoryKey[0]}::integer, ${advisoryKey[1]}::integer) IS NULL`;
|
||||||
|
acquiredAdvisoryLock();
|
||||||
|
await hold;
|
||||||
|
});
|
||||||
|
await Promise.race([locked, lockHolder.then(() => { throw new Error("The advisory lock holder ended before acquiring its lock."); })]);
|
||||||
|
return { blockerPid, release: releaseAdvisoryLock, cleanup };
|
||||||
|
} catch (error) {
|
||||||
|
await cleanup();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createWorkGraphNodeUpdateBarrier = async (nodeId: string) => {
|
||||||
|
const suffix = randomUUID().replaceAll("-", "");
|
||||||
|
const triggerName = `test_node_${suffix}`;
|
||||||
|
const functionName = `${triggerName}_fn`;
|
||||||
|
const advisoryKey = [Number.parseInt(suffix.slice(0, 7), 16), Number.parseInt(suffix.slice(7, 14), 16)] as const;
|
||||||
|
let blockerPid = 0;
|
||||||
|
let releaseAdvisoryLock: () => void = () => undefined;
|
||||||
|
let acquiredAdvisoryLock!: () => void;
|
||||||
|
let functionCreated = false;
|
||||||
|
let triggerCreated = false;
|
||||||
|
let lockHolder: Promise<void> | undefined;
|
||||||
|
const locked = new Promise<void>((resolve) => { acquiredAdvisoryLock = resolve; });
|
||||||
|
const hold = new Promise<void>((resolve) => { releaseAdvisoryLock = resolve; });
|
||||||
|
const cleanup = async () => {
|
||||||
|
releaseAdvisoryLock();
|
||||||
|
if (lockHolder) await lockHolder.catch(() => undefined);
|
||||||
|
if (triggerCreated) await prisma.$executeRawUnsafe(`DROP TRIGGER IF EXISTS ${triggerName} ON "WorkGraphNode"`);
|
||||||
|
if (functionCreated) await prisma.$executeRawUnsafe(`DROP FUNCTION IF EXISTS ${functionName}()`);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE FUNCTION ${functionName}() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_advisory_xact_lock(${advisoryKey[0]}, ${advisoryKey[1]});
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
functionCreated = true;
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TRIGGER ${triggerName}
|
||||||
|
BEFORE UPDATE ON "WorkGraphNode"
|
||||||
|
FOR EACH ROW WHEN (NEW."id" = '${nodeId}')
|
||||||
|
EXECUTE FUNCTION ${functionName}();
|
||||||
|
`);
|
||||||
|
triggerCreated = true;
|
||||||
|
lockHolder = prisma.$transaction(async (transaction) => {
|
||||||
|
const [backend] = await transaction.$queryRaw<Array<{ pid: number }>>`SELECT pg_backend_pid() AS pid`;
|
||||||
|
blockerPid = backend!.pid;
|
||||||
|
await transaction.$queryRaw`SELECT pg_advisory_xact_lock(${advisoryKey[0]}::integer, ${advisoryKey[1]}::integer) IS NULL`;
|
||||||
|
acquiredAdvisoryLock();
|
||||||
|
await hold;
|
||||||
|
});
|
||||||
|
await Promise.race([locked, lockHolder.then(() => { throw new Error("The advisory lock holder ended before acquiring its lock."); })]);
|
||||||
|
return { blockerPid, release: releaseAdvisoryLock, cleanup };
|
||||||
|
} catch (error) {
|
||||||
|
await cleanup();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
databaseDescribe("autonomy database write isolation", () => {
|
databaseDescribe("autonomy database write isolation", () => {
|
||||||
it("preserves progress and the active lease when the same roadmap is imported again", async () => {
|
it("preserves progress and the active lease when the same roadmap is imported again", async () => {
|
||||||
const slug = `autonomy-import-${Date.now()}`;
|
const slug = `autonomy-import-${Date.now()}`;
|
||||||
@@ -107,7 +301,10 @@ databaseDescribe("autonomy database write isolation", () => {
|
|||||||
vi.spyOn(GiteaClient.prototype, "getBranch").mockResolvedValue({ name: "chatgpt/existing", commit: { id: head, message: "Repair" }, protected: false });
|
vi.spyOn(GiteaClient.prototype, "getBranch").mockResolvedValue({ name: "chatgpt/existing", commit: { id: head, message: "Repair" }, protected: false });
|
||||||
vi.spyOn(GiteaClient.prototype, "getContents").mockResolvedValue({ type: "file", path: "src/feature.ts", name: "feature.ts", sha: "file-sha", size: 4, encoding: "base64", content: "dGVzdA==" });
|
vi.spyOn(GiteaClient.prototype, "getContents").mockResolvedValue({ type: "file", path: "src/feature.ts", name: "feature.ts", sha: "file-sha", size: 4, encoding: "base64", content: "dGVzdA==" });
|
||||||
const write = vi.spyOn(GiteaClient.prototype, "updateFile").mockResolvedValue({});
|
const write = vi.spyOn(GiteaClient.prototype, "updateFile").mockResolvedValue({});
|
||||||
vi.spyOn(GiteaClient.prototype, "listActionRuns").mockResolvedValue({ total_count: 1, workflow_runs: [{ id: 2, run_number: 2, display_title: "CI", path: ".gitea/workflows/ci.yml", event: "pull_request", head_sha: head, head_branch: "chatgpt/existing", status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/2" }] });
|
vi.spyOn(GiteaClient.prototype, "listActionRuns").mockResolvedValue({ total_count: 2, workflow_runs: [
|
||||||
|
{ id: 2, run_number: 2, display_title: "Managed validation / full", path: ".gitea/workflows/managed-validation.yml", event: "pull_request", head_sha: head, head_branch: "chatgpt/existing", status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/2" },
|
||||||
|
{ id: 3, run_number: 3, display_title: "CI", path: ".gitea/workflows/ci.yml", event: "pull_request", head_sha: head, head_branch: "chatgpt/existing", status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/3" },
|
||||||
|
] });
|
||||||
vi.spyOn(GiteaClient.prototype, "getBranchProtection").mockResolvedValue({ enable_status_check: true, status_check_contexts: ["CI / test"] });
|
vi.spyOn(GiteaClient.prototype, "getBranchProtection").mockResolvedValue({ enable_status_check: true, status_check_contexts: ["CI / test"] });
|
||||||
vi.spyOn(GiteaClient.prototype, "getCombinedCommitStatus").mockResolvedValue({ state: "success", statuses: [{ context: "CI / test", status: "success" }] });
|
vi.spyOn(GiteaClient.prototype, "getCombinedCommitStatus").mockResolvedValue({ state: "success", statuses: [{ context: "CI / test", status: "success" }] });
|
||||||
const merge = vi.spyOn(GiteaClient.prototype, "mergePullRequest").mockImplementation(async () => { remoteMerged = true; });
|
const merge = vi.spyOn(GiteaClient.prototype, "mergePullRequest").mockImplementation(async () => { remoteMerged = true; });
|
||||||
@@ -162,4 +359,379 @@ databaseDescribe("autonomy database write isolation", () => {
|
|||||||
expect(integrated.spec.validationEvidence).toBeUndefined();
|
expect(integrated.spec.validationEvidence).toBeUndefined();
|
||||||
expect(compare).toHaveBeenLastCalledWith("AutonomyProof", slug, "b".repeat(40), "a".repeat(40));
|
expect(compare).toHaveBeenLastCalledWith("AutonomyProof", slug, "b".repeat(40), "a".repeat(40));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serializes concurrent dependency edits before checking for cycles", async () => {
|
||||||
|
const { slug, project, graph } = await createWorkGraphProject("## Work\n- Task A\n- Task B");
|
||||||
|
const taskA = nodeByTitle(graph, "Task A");
|
||||||
|
const taskB = nodeByTitle(graph, "Task B");
|
||||||
|
const suffix = randomUUID().replaceAll("-", "");
|
||||||
|
const triggerName = `test_cycle_${suffix}`;
|
||||||
|
const functionName = `${triggerName}_fn`;
|
||||||
|
const advisoryKey = [Number.parseInt(suffix.slice(0, 7), 16), Number.parseInt(suffix.slice(7, 14), 16)] as const;
|
||||||
|
let blockerPid = 0;
|
||||||
|
let releaseAdvisoryLock: () => void = () => undefined;
|
||||||
|
let acquiredAdvisoryLock!: () => void;
|
||||||
|
let functionCreated = false;
|
||||||
|
let triggerCreated = false;
|
||||||
|
const locked = new Promise<void>((resolve) => { acquiredAdvisoryLock = resolve; });
|
||||||
|
const hold = new Promise<void>((resolve) => { releaseAdvisoryLock = resolve; });
|
||||||
|
let lockHolder: Promise<void> | undefined;
|
||||||
|
let edits: Array<Promise<Awaited<ReturnType<typeof updateWorkDependencies>>>> | undefined;
|
||||||
|
try {
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE FUNCTION ${functionName}() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_advisory_xact_lock(${advisoryKey[0]}, ${advisoryKey[1]});
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
functionCreated = true;
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TRIGGER ${triggerName}
|
||||||
|
BEFORE INSERT ON "WorkGraphDependency"
|
||||||
|
FOR EACH ROW WHEN (NEW."projectId" = '${project.id}')
|
||||||
|
EXECUTE FUNCTION ${functionName}();
|
||||||
|
`);
|
||||||
|
triggerCreated = true;
|
||||||
|
lockHolder = prisma.$transaction(async (transaction) => {
|
||||||
|
const [backend] = await transaction.$queryRaw<Array<{ pid: number }>>`SELECT pg_backend_pid() AS pid`;
|
||||||
|
blockerPid = backend!.pid;
|
||||||
|
await transaction.$queryRaw`SELECT pg_advisory_xact_lock(${advisoryKey[0]}::integer, ${advisoryKey[1]}::integer) IS NULL`;
|
||||||
|
acquiredAdvisoryLock();
|
||||||
|
await hold;
|
||||||
|
});
|
||||||
|
await Promise.race([locked, lockHolder.then(() => { throw new Error("The advisory lock holder ended before acquiring its lock."); })]);
|
||||||
|
edits = [
|
||||||
|
updateWorkDependencies(slug, taskA.id, [taskB.id], "database-test"),
|
||||||
|
updateWorkDependencies(slug, taskB.id, [taskA.id], "database-test"),
|
||||||
|
];
|
||||||
|
const waitDeadline = Date.now() + 2000;
|
||||||
|
let bothAtSerializedBoundary = false;
|
||||||
|
while (!bothAtSerializedBoundary && Date.now() < waitDeadline) {
|
||||||
|
const waiters = await readDatabaseLockWaiters();
|
||||||
|
const blockedInserts = waiters.filter((row) => row.query.toLowerCase().includes("workgraphdependency") && row.blockingPids.includes(blockerPid));
|
||||||
|
const projectLockWaiters = waiters.filter((row) => isProjectCoordinationLockQuery(row.query));
|
||||||
|
const baselinePair = blockedInserts.length >= 2;
|
||||||
|
const serializedPair = blockedInserts.length === 1 && projectLockWaiters.some((row) => row.blockingPids.includes(blockedInserts[0]!.pid));
|
||||||
|
bothAtSerializedBoundary = baselinePair || serializedPair;
|
||||||
|
if (!bothAtSerializedBoundary) await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
expect(bothAtSerializedBoundary).toBe(true);
|
||||||
|
releaseAdvisoryLock();
|
||||||
|
const results = await Promise.allSettled(edits);
|
||||||
|
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
|
||||||
|
const rejected = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
||||||
|
expect(rejected).toHaveLength(1);
|
||||||
|
expect(rejected[0]?.reason).toMatchObject({ message: "WORK_GRAPH_DEPENDENCY_CYCLE" });
|
||||||
|
const finalGraph = await getProjectWorkGraph(slug);
|
||||||
|
const finalA = nodeByTitle(finalGraph, "Task A");
|
||||||
|
const finalB = nodeByTitle(finalGraph, "Task B");
|
||||||
|
expect(finalA.dependencies.some((edge) => edge.dependsOnNodeId === finalB.id)).not.toBe(finalB.dependencies.some((edge) => edge.dependsOnNodeId === finalA.id));
|
||||||
|
} finally {
|
||||||
|
releaseAdvisoryLock();
|
||||||
|
if (lockHolder) await lockHolder.catch(() => undefined);
|
||||||
|
if (edits) await Promise.allSettled(edits);
|
||||||
|
if (triggerCreated) await prisma.$executeRawUnsafe(`DROP TRIGGER IF EXISTS ${triggerName} ON "WorkGraphDependency"`);
|
||||||
|
if (functionCreated) await prisma.$executeRawUnsafe(`DROP FUNCTION IF EXISTS ${functionName}()`);
|
||||||
|
}
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
it("does not attach a new unresolved prerequisite to work with an active lease", async () => {
|
||||||
|
const { slug, project } = await createWorkGraphProject("## Work\n- Runnable task\n- Prerequisite task");
|
||||||
|
await setAutonomyPriority(project.id, slug, "Runnable task", 100);
|
||||||
|
const currentGraph = await getProjectWorkGraph(slug);
|
||||||
|
const task = nodeByTitle(currentGraph, "Runnable task");
|
||||||
|
const prerequisite = nodeByTitle(currentGraph, "Prerequisite task");
|
||||||
|
const beforeDependencies = await prisma.workGraphDependency.findMany({ where: { projectId: project.id, nodeId: task.id }, select: { dependsOnNodeId: true, dependencyType: true, createdBy: true } });
|
||||||
|
expect(beforeDependencies).toHaveLength(0);
|
||||||
|
const claim = await claimReadyWork(slug, "claimant");
|
||||||
|
expect(claim?.node.id).toBe(task.id);
|
||||||
|
expect(claim?.node.dependencies.map((edge) => edge.dependsOnNodeId)).toEqual(beforeDependencies.map((edge) => edge.dependsOnNodeId));
|
||||||
|
await expect(updateWorkDependencies(slug, task.id, [prerequisite.id], "database-test")).rejects.toThrow("WORK_GRAPH_ACTIVE_LEASE");
|
||||||
|
const persisted = await prisma.workGraphDependency.findMany({ where: { projectId: project.id, nodeId: task.id }, select: { dependsOnNodeId: true, dependencyType: true, createdBy: true } });
|
||||||
|
expect(persisted).toEqual(beforeDependencies);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes a dependency edit behind an in-flight claim", async () => {
|
||||||
|
const { slug, project } = await createWorkGraphProject("## Work\n- Runnable task\n- Prerequisite task");
|
||||||
|
await setAutonomyPriority(project.id, slug, "Runnable task", 100);
|
||||||
|
const currentGraph = await getProjectWorkGraph(slug);
|
||||||
|
const task = nodeByTitle(currentGraph, "Runnable task");
|
||||||
|
const prerequisite = nodeByTitle(currentGraph, "Prerequisite task");
|
||||||
|
const suffix = randomUUID().replaceAll("-", "");
|
||||||
|
const triggerName = `test_claim_${suffix}`;
|
||||||
|
const functionName = `${triggerName}_fn`;
|
||||||
|
const advisoryKey = [Number.parseInt(suffix.slice(0, 7), 16), Number.parseInt(suffix.slice(7, 14), 16)] as const;
|
||||||
|
|
||||||
|
let releaseAdvisoryLock: () => void = () => undefined;
|
||||||
|
let acquiredAdvisoryLock!: () => void;
|
||||||
|
let blockerPid = 0;
|
||||||
|
let functionCreated = false;
|
||||||
|
let triggerCreated = false;
|
||||||
|
const beforeDependencies = await prisma.workGraphDependency.findMany({ where: { projectId: project.id, nodeId: task.id }, select: { dependsOnNodeId: true, dependencyType: true, createdBy: true } });
|
||||||
|
expect(beforeDependencies).toHaveLength(0);
|
||||||
|
const locked = new Promise<void>((resolve) => { acquiredAdvisoryLock = resolve; });
|
||||||
|
const hold = new Promise<void>((resolve) => { releaseAdvisoryLock = resolve; });
|
||||||
|
let claim: ReturnType<typeof claimReadyWork> | undefined;
|
||||||
|
let edit: Promise<{ status: "fulfilled" } | { status: "rejected"; error: unknown }> | undefined;
|
||||||
|
let editSettled = false;
|
||||||
|
let lockHolder: Promise<void> | undefined;
|
||||||
|
try {
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE FUNCTION ${functionName}() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_advisory_xact_lock(${advisoryKey[0]}, ${advisoryKey[1]});
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
functionCreated = true;
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TRIGGER ${triggerName}
|
||||||
|
BEFORE UPDATE ON "WorkGraphNode"
|
||||||
|
FOR EACH ROW WHEN (NEW."projectId" = '${project.id}' AND NEW."owner" IS NOT NULL AND OLD."owner" IS DISTINCT FROM NEW."owner")
|
||||||
|
EXECUTE FUNCTION ${functionName}();
|
||||||
|
`);
|
||||||
|
triggerCreated = true;
|
||||||
|
lockHolder = prisma.$transaction(async (transaction) => {
|
||||||
|
const [backend] = await transaction.$queryRaw<Array<{ pid: number }>>`SELECT pg_backend_pid() AS pid`;
|
||||||
|
blockerPid = backend!.pid;
|
||||||
|
await transaction.$queryRaw`SELECT pg_advisory_xact_lock(${advisoryKey[0]}::integer, ${advisoryKey[1]}::integer) IS NULL`;
|
||||||
|
acquiredAdvisoryLock();
|
||||||
|
await hold;
|
||||||
|
});
|
||||||
|
await Promise.race([locked, lockHolder!.then(() => { throw new Error("The advisory lock holder ended before acquiring its lock."); })]);
|
||||||
|
claim = claimReadyWork(slug, "claimant");
|
||||||
|
const waitDeadline = Date.now() + 2000;
|
||||||
|
let claimWaiter: DatabaseLockWaiter | undefined;
|
||||||
|
while (!claimWaiter && Date.now() < waitDeadline) {
|
||||||
|
claimWaiter = (await readDatabaseLockWaiters()).find((row) => row.query.toLowerCase().includes("update") && row.query.toLowerCase().includes("workgraphnode") && row.blockingPids.includes(blockerPid));
|
||||||
|
if (!claimWaiter) await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
expect(claimWaiter).toBeDefined();
|
||||||
|
|
||||||
|
edit = updateWorkDependencies(slug, task.id, [prerequisite.id], "database-test").then(
|
||||||
|
() => ({ status: "fulfilled" as const }),
|
||||||
|
(error: unknown) => ({ status: "rejected" as const, error }),
|
||||||
|
).finally(() => { editSettled = true; });
|
||||||
|
const editWaitDeadline = Date.now() + 2000;
|
||||||
|
let dependencyEditWaiter: DatabaseLockWaiter | undefined;
|
||||||
|
while (!dependencyEditWaiter && !editSettled && Date.now() < editWaitDeadline) {
|
||||||
|
dependencyEditWaiter = (await readDatabaseLockWaiters()).find((row) => isProjectCoordinationLockQuery(row.query) && row.blockingPids.includes(claimWaiter!.pid));
|
||||||
|
if (!dependencyEditWaiter && !editSettled) await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
expect(editSettled).toBe(false);
|
||||||
|
expect(dependencyEditWaiter).toBeDefined();
|
||||||
|
} finally {
|
||||||
|
releaseAdvisoryLock?.();
|
||||||
|
if (lockHolder) await lockHolder.catch(() => undefined);
|
||||||
|
if (claim) await claim.catch(() => undefined);
|
||||||
|
if (edit) await edit.catch(() => undefined);
|
||||||
|
if (triggerCreated) await prisma.$executeRawUnsafe(`DROP TRIGGER IF EXISTS ${triggerName} ON "WorkGraphNode"`);
|
||||||
|
if (functionCreated) await prisma.$executeRawUnsafe(`DROP FUNCTION IF EXISTS ${functionName}()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimResult = await claim!;
|
||||||
|
expect(claimResult?.node.id).toBe(task.id);
|
||||||
|
expect(claimResult?.node.dependencies.map((edge) => edge.dependsOnNodeId)).toEqual(beforeDependencies.map((edge) => edge.dependsOnNodeId));
|
||||||
|
const editResult = await edit!;
|
||||||
|
expect(editResult.status).toBe("rejected");
|
||||||
|
if (editResult.status === "rejected") expect(editResult.error).toMatchObject({ message: "WORK_GRAPH_ACTIVE_LEASE" });
|
||||||
|
const persisted = await prisma.workGraphDependency.findMany({ where: { projectId: project.id, nodeId: task.id }, select: { dependsOnNodeId: true, dependencyType: true, createdBy: true } });
|
||||||
|
expect(persisted).toEqual(beforeDependencies);
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
it("keeps completion audit foreign-key checks compatible with a waiting graph sync", async () => {
|
||||||
|
const { slug, project, graph } = await createWorkGraphProject("## Work\n- Completing task\n- Other task");
|
||||||
|
await setAutonomyPriority(project.id, slug, "Completing task", 100);
|
||||||
|
const task = nodeByTitle(graph, "Completing task");
|
||||||
|
const claim = await claimReadyWork(slug, "completion-worker");
|
||||||
|
expect(claim?.node.id).toBe(task.id);
|
||||||
|
const barrier = await createAuditInsertBarrier(project.id, "work.completed");
|
||||||
|
let completion: ReturnType<typeof recordWorkCompletion> | undefined;
|
||||||
|
let sync: ReturnType<typeof syncProjectWorkGraph> | undefined;
|
||||||
|
try {
|
||||||
|
completion = recordWorkCompletion(slug, task.id, "completion-worker", claim!.leaseToken, { summary: "Completion lock-order proof" });
|
||||||
|
const completionWaiter = await waitForDatabaseLockWaiter((waiter) => waiter.query.toLowerCase().includes("workgraphauditevent") && waiter.blockingPids.includes(barrier.blockerPid));
|
||||||
|
expect(completionWaiter).toBeDefined();
|
||||||
|
|
||||||
|
sync = syncProjectWorkGraph(slug);
|
||||||
|
const syncWaiter = await waitForDatabaseLockWaiter((waiter) => waiter.query.toLowerCase().includes("workgraphnode") && waiter.blockingPids.includes(completionWaiter!.pid) && waiter.hasProjectRowShareLock);
|
||||||
|
expect(syncWaiter).toBeDefined();
|
||||||
|
|
||||||
|
barrier.release();
|
||||||
|
const results = await Promise.allSettled([completion, sync]);
|
||||||
|
expect(results.map((result) => result.status)).toEqual(["fulfilled", "fulfilled"]);
|
||||||
|
} finally {
|
||||||
|
barrier.release();
|
||||||
|
await Promise.allSettled([...(completion ? [completion] : []), ...(sync ? [sync] : [])]);
|
||||||
|
await barrier.cleanup();
|
||||||
|
}
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
it("keeps expired-work audit foreign-key checks compatible with a waiting claim", async () => {
|
||||||
|
const { slug, project, graph } = await createWorkGraphProject("## Work\n- Expired task\n- Other task");
|
||||||
|
await setAutonomyPriority(project.id, slug, "Expired task", 100);
|
||||||
|
const task = nodeByTitle(graph, "Expired task");
|
||||||
|
const initialClaim = await claimReadyWork(slug, "expired-worker");
|
||||||
|
expect(initialClaim?.node.id).toBe(task.id);
|
||||||
|
await prisma.workGraphNode.update({ where: { id: task.id }, data: { leaseExpiresAt: new Date(Date.now() - 60_000) } });
|
||||||
|
const barrier = await createAuditInsertBarrier(project.id, "work.reclaimed");
|
||||||
|
let reclaim: ReturnType<typeof reclaimExpiredWork> | undefined;
|
||||||
|
let claimAfterExpiry: ReturnType<typeof claimReadyWork> | undefined;
|
||||||
|
try {
|
||||||
|
reclaim = reclaimExpiredWork(slug);
|
||||||
|
const reclaimWaiter = await waitForDatabaseLockWaiter((waiter) => waiter.query.toLowerCase().includes("workgraphauditevent") && waiter.blockingPids.includes(barrier.blockerPid));
|
||||||
|
expect(reclaimWaiter).toBeDefined();
|
||||||
|
|
||||||
|
claimAfterExpiry = claimReadyWork(slug, "reclaim-race-claimant");
|
||||||
|
const claimWaiter = await waitForDatabaseLockWaiter((waiter) => isProjectCoordinationLockQuery(waiter.query) && waiter.blockingPids.includes(reclaimWaiter!.pid));
|
||||||
|
expect(claimWaiter).toBeDefined();
|
||||||
|
|
||||||
|
barrier.release();
|
||||||
|
const [reclaimResult, claimResult] = await Promise.all([reclaim, claimAfterExpiry]);
|
||||||
|
expect(reclaimResult.reclaimedCount).toBe(1);
|
||||||
|
expect(claimResult?.node.id).toBe(task.id);
|
||||||
|
} finally {
|
||||||
|
barrier.release();
|
||||||
|
await Promise.allSettled([...(reclaim ? [reclaim] : []), ...(claimAfterExpiry ? [claimAfterExpiry] : [])]);
|
||||||
|
await barrier.cleanup();
|
||||||
|
}
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
it("serializes multi-node reclaim before it can invert locks with graph reconciliation", async () => {
|
||||||
|
const { slug, project, graph } = await createWorkGraphProject("## Work\n- Task B\n- Task A");
|
||||||
|
const taskA = nodeByTitle(graph, "Task A");
|
||||||
|
const taskB = nodeByTitle(graph, "Task B");
|
||||||
|
const now = Date.now();
|
||||||
|
await prisma.workGraphNode.update({ where: { id: taskB.id }, data: { owner: "expired-worker", leaseToken: randomUUID(), leaseExpiresAt: new Date(now - 120_000), executionEligibility: "running", canonicalLifecycle: "running" } });
|
||||||
|
await prisma.workGraphNode.update({ where: { id: taskA.id }, data: { owner: "expired-worker", leaseToken: randomUUID(), leaseExpiresAt: new Date(now - 60_000), executionEligibility: "running", canonicalLifecycle: "running" } });
|
||||||
|
const reclaimWhere = { projectId: project.id, leaseExpiresAt: { lt: new Date() }, owner: { not: null }, executionEligibility: "running" as const };
|
||||||
|
const reclaimOrder = await prisma.workGraphNode.findMany({ where: reclaimWhere, select: { id: true, title: true } });
|
||||||
|
expect(reclaimOrder).toHaveLength(2);
|
||||||
|
const graphOrder = reclaimOrder.map((node) => node.title).reverse();
|
||||||
|
await setAutonomyRoadmapOrder(project.id, slug, graphOrder);
|
||||||
|
|
||||||
|
const graphFirstNode = graphOrder[0] === "Task A" ? taskA : taskB;
|
||||||
|
const barrier = await createWorkGraphNodeUpdateBarrier(graphFirstNode.id);
|
||||||
|
let sync: ReturnType<typeof syncProjectWorkGraph> | undefined;
|
||||||
|
let reclaim: ReturnType<typeof reclaimExpiredWork> | undefined;
|
||||||
|
try {
|
||||||
|
sync = syncProjectWorkGraph(slug);
|
||||||
|
const syncWaiter = await waitForDatabaseLockWaiter((waiter) => waiter.query.toLowerCase().includes("workgraphnode") && waiter.blockingPids.includes(barrier.blockerPid) && waiter.hasProjectRowShareLock);
|
||||||
|
expect(syncWaiter).toBeDefined();
|
||||||
|
|
||||||
|
reclaim = reclaimExpiredWork(slug);
|
||||||
|
const reclaimProjectWaiter = await waitForDatabaseLockWaiter((waiter) => isProjectCoordinationLockQuery(waiter.query) && waiter.blockingPids.includes(syncWaiter!.pid));
|
||||||
|
const reclaimNodeWaiter = reclaimProjectWaiter ? undefined : await waitForDatabaseLockWaiter((waiter) => waiter.query.toLowerCase().includes("workgraphnode") && waiter.blockingPids.includes(syncWaiter!.pid));
|
||||||
|
expect(reclaimProjectWaiter ?? reclaimNodeWaiter).toBeDefined();
|
||||||
|
|
||||||
|
barrier.release();
|
||||||
|
if (reclaimNodeWaiter) {
|
||||||
|
const graphReverseWaiter = await waitForDatabaseLockWaiter((waiter) => waiter.query.toLowerCase().includes("workgraphnode") && waiter.blockingPids.includes(reclaimNodeWaiter.pid) && waiter.hasProjectRowShareLock);
|
||||||
|
expect(graphReverseWaiter).toBeDefined();
|
||||||
|
}
|
||||||
|
const results = await Promise.allSettled([sync, reclaim]);
|
||||||
|
const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
||||||
|
expect(results.map((result) => result.status), failures.map((failure) => String(failure.reason)).join("\n")).toEqual(["fulfilled", "fulfilled"]);
|
||||||
|
} finally {
|
||||||
|
barrier.release();
|
||||||
|
await Promise.allSettled([...(sync ? [sync] : []), ...(reclaim ? [reclaim] : [])]);
|
||||||
|
await barrier.cleanup();
|
||||||
|
}
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
it("keeps a live lease's derived dependencies until expiry, then applies the latest source edges", async () => {
|
||||||
|
const { slug, project } = await createWorkGraphProject("## Work\n- Runnable task\n- Prerequisite task");
|
||||||
|
await setAutonomyPriority(project.id, slug, "Runnable task", 100);
|
||||||
|
const graph = await getProjectWorkGraph(slug);
|
||||||
|
const task = nodeByTitle(graph, "Runnable task");
|
||||||
|
const prerequisite = nodeByTitle(graph, "Prerequisite task");
|
||||||
|
const claim = await claimReadyWork(slug, "claimant");
|
||||||
|
expect(claim?.node.id).toBe(task.id);
|
||||||
|
expect(claim?.node.dependencies).toHaveLength(0);
|
||||||
|
|
||||||
|
await setAutonomyDependencies(project.id, slug, "Runnable task", ["Prerequisite task"]);
|
||||||
|
const duringLease = await getProjectWorkGraph(slug);
|
||||||
|
const leasedTask = nodeByTitle(duringLease, "Runnable task");
|
||||||
|
expect(leasedTask.executionEligibility).toBe("running");
|
||||||
|
expect(leasedTask.dependencies).toHaveLength(0);
|
||||||
|
|
||||||
|
await prisma.workGraphNode.update({ where: { id: task.id }, data: { leaseExpiresAt: new Date(Date.now() - 60_000) } });
|
||||||
|
const afterExpiry = await getProjectWorkGraph(slug);
|
||||||
|
const blockedTask = nodeByTitle(afterExpiry, "Runnable task");
|
||||||
|
expect(blockedTask.executionEligibility).toBe("waiting");
|
||||||
|
expect(blockedTask.dependencies.map((edge) => edge.dependsOnNodeId)).toEqual([prerequisite.id]);
|
||||||
|
const nextClaim = await claimReadyWork(slug, "next-claimant");
|
||||||
|
expect(nextClaim?.node.id).toBe(prerequisite.id);
|
||||||
|
expect(nextClaim?.node.id).not.toBe(task.id);
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
it("keeps the old complete derived edge set visible until reconciliation commits", async () => {
|
||||||
|
const { slug, project } = await createWorkGraphProject("## Work\n- Task A\n- Task B\n- Task C");
|
||||||
|
await setAutonomyDependencies(project.id, slug, "Task A", ["Task B"]);
|
||||||
|
const initialGraph = await getProjectWorkGraph(slug);
|
||||||
|
const taskA = nodeByTitle(initialGraph, "Task A");
|
||||||
|
const taskB = nodeByTitle(initialGraph, "Task B");
|
||||||
|
const taskC = nodeByTitle(initialGraph, "Task C");
|
||||||
|
await setAutonomyDependencies(project.id, slug, "Task A", ["Task C"]);
|
||||||
|
|
||||||
|
let releaseProjectLock!: () => void;
|
||||||
|
let acquiredProjectLock!: () => void;
|
||||||
|
let blockerPid = 0;
|
||||||
|
const projectLocked = new Promise<void>((resolve) => { acquiredProjectLock = resolve; });
|
||||||
|
const hold = new Promise<void>((resolve) => { releaseProjectLock = resolve; });
|
||||||
|
const lockHolder = prisma.$transaction(async (transaction) => {
|
||||||
|
const [backend] = await transaction.$queryRaw<Array<{ pid: number }>>`SELECT pg_backend_pid() AS pid`;
|
||||||
|
blockerPid = backend!.pid;
|
||||||
|
await transaction.$queryRaw`SELECT "id" FROM "Project" WHERE "id" = ${project.id} FOR UPDATE`;
|
||||||
|
acquiredProjectLock();
|
||||||
|
await hold;
|
||||||
|
});
|
||||||
|
let sync: Promise<string> | undefined;
|
||||||
|
try {
|
||||||
|
await Promise.race([projectLocked, lockHolder.then(() => { throw new Error("The project lock holder ended before acquiring its lock."); })]);
|
||||||
|
let syncFinished = false;
|
||||||
|
sync = syncProjectWorkGraph(slug).then((result) => { syncFinished = true; return result; });
|
||||||
|
const waitDeadline = Date.now() + 5000;
|
||||||
|
let waitingTransactions: DatabaseLockWaiter[] = [];
|
||||||
|
while (!waitingTransactions.length && Date.now() < waitDeadline) {
|
||||||
|
waitingTransactions = (await readDatabaseLockWaiters()).filter((row) => row.blockingPids.includes(blockerPid));
|
||||||
|
if (!waitingTransactions.length) await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
expect(waitingTransactions.length).toBeGreaterThan(0);
|
||||||
|
expect(syncFinished).toBe(false);
|
||||||
|
const duringSync = await prisma.workGraphDependency.findMany({ where: { projectId: project.id, createdBy: "autonomy-sync" } });
|
||||||
|
expect(duringSync.map((edge) => [edge.nodeId, edge.dependsOnNodeId])).toEqual([[taskA.id, taskB.id]]);
|
||||||
|
} finally {
|
||||||
|
releaseProjectLock();
|
||||||
|
await lockHolder.catch(() => undefined);
|
||||||
|
if (sync) await sync;
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterSync = await prisma.workGraphDependency.findMany({ where: { projectId: project.id, createdBy: "autonomy-sync" } });
|
||||||
|
expect(afterSync.map((edge) => [edge.nodeId, edge.dependsOnNodeId])).toEqual([[taskA.id, taskC.id]]);
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
it("rechecks prerequisites before treating an expired lease as reclaimable", async () => {
|
||||||
|
const { slug, project, graph } = await createWorkGraphProject("## Work\n- Dependent task\n- Prerequisite task");
|
||||||
|
const task = nodeByTitle(graph, "Dependent task");
|
||||||
|
const prerequisite = nodeByTitle(graph, "Prerequisite task");
|
||||||
|
await updateWorkDependencies(slug, task.id, [prerequisite.id], "database-test");
|
||||||
|
await prisma.workGraphNode.update({ where: { id: task.id }, data: {
|
||||||
|
owner: "expired-worker",
|
||||||
|
leaseToken: randomUUID(),
|
||||||
|
leaseExpiresAt: new Date(Date.now() - 60_000),
|
||||||
|
executionEligibility: "running",
|
||||||
|
canonicalLifecycle: "running",
|
||||||
|
} });
|
||||||
|
|
||||||
|
const snapshot = await getProjectWorkGraph(slug);
|
||||||
|
expect(nodeByTitle(snapshot, "Dependent task").executionEligibility).toBe("waiting");
|
||||||
|
expect(nodeByTitle(snapshot, "Dependent task").dependencies.some((edge) => edge.dependsOnNodeId === prerequisite.id)).toBe(true);
|
||||||
|
const claim = await claimReadyWork(slug, "claimant");
|
||||||
|
expect(claim?.node.id).toBe(prerequisite.id);
|
||||||
|
expect(claim?.node.id).not.toBe(task.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -107,13 +107,36 @@ describe("Gitea webhook verification", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Gitea API client", () => {
|
describe("Gitea API client", () => {
|
||||||
it("accepts Gitea's empty HTTP 200 merge response without inventing a failed merge", async () => {
|
it.each([
|
||||||
const fetcher = vi.fn(async (input: string | URL | Request) => String(input).endsWith("/version")
|
["1.14.0", "Do"],
|
||||||
? new Response(JSON.stringify({ version: "1.27.3" }), { status: 200 })
|
["1.14.7", "Do"],
|
||||||
: new Response(null, { status: 200 }));
|
["1.25.5", "Do"],
|
||||||
|
["1.26.4", "do"],
|
||||||
|
["1.27.3", "do"],
|
||||||
|
["28.0.0", "do"],
|
||||||
|
["28.1.3", "do"],
|
||||||
|
] as const)("uses the supported Gitea merge field for %s", async (version, expectedKey) => {
|
||||||
|
const bodies: Record<string, unknown>[] = [];
|
||||||
|
const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
if (String(input).endsWith("/version")) return new Response(JSON.stringify({ version }), { status: 200 });
|
||||||
|
bodies.push(JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>);
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
});
|
||||||
const client = new GiteaClient("https://gitea.example.com", "test-token", fetcher as typeof fetch);
|
const client = new GiteaClient("https://gitea.example.com", "test-token", fetcher as typeof fetch);
|
||||||
await expect(client.mergePullRequest("Owner", "Repo", 7, { method: "merge", headCommitId: "a".repeat(40) })).resolves.toBeUndefined();
|
await expect(client.mergePullRequest("Owner", "Repo", 7, { method: "merge", headCommitId: "a".repeat(40) })).resolves.toBeUndefined();
|
||||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||||
|
expect(bodies[0]?.[expectedKey]).toBe("merge");
|
||||||
|
expect(bodies[0]?.[expectedKey === "Do" ? "do" : "Do"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["1.24.6", "29.0.0", "not-a-version", ""])("fails closed for unsupported Gitea merge version %s", async (version) => {
|
||||||
|
const fetcher = vi.fn(async (input: string | URL | Request) => String(input).endsWith("/version")
|
||||||
|
? new Response(JSON.stringify({ version }), { status: 200 })
|
||||||
|
: new Response(null, { status: 200 }));
|
||||||
|
const client = new GiteaClient("https://gitea.example.com", "test-token", fetcher as typeof fetch);
|
||||||
|
await expect(client.mergePullRequest("Owner", "Repo", 7, { method: "merge", headCommitId: "a".repeat(40) }))
|
||||||
|
.rejects.toMatchObject({ code: "GITEA_VERSION_UNSUPPORTED" });
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
it("creates a private initialized repository through the bounded user endpoint", async () => {
|
it("creates a private initialized repository through the bounded user endpoint", async () => {
|
||||||
const fetcher = vi.fn(async () => new Response(JSON.stringify({
|
const fetcher = vi.fn(async () => new Response(JSON.stringify({
|
||||||
@@ -509,11 +532,11 @@ describe("ChatGPT feature delivery approval", () => {
|
|||||||
|
|
||||||
it("requires every Gitea Actions workflow for the exact branch head to succeed", () => {
|
it("requires every Gitea Actions workflow for the exact branch head to succeed", () => {
|
||||||
const run = (id: number, conclusion: string, headSha = "current-sha"): GiteaActionRun => ({
|
const run = (id: number, conclusion: string, headSha = "current-sha"): GiteaActionRun => ({
|
||||||
id, run_number: id, display_title: "CI", path: ".gitea/workflows/ci.yml", event: "push", head_sha: headSha,
|
id, run_number: id, display_title: "Managed validation / full", path: ".gitea/workflows/managed-validation.yml", event: "workflow_dispatch", head_sha: headSha,
|
||||||
head_branch: "chatgpt/feature", status: "completed", conclusion, html_url: `https://gitea.example.com/runs/${id}`,
|
head_branch: "chatgpt/feature", status: "completed", conclusion, html_url: `https://gitea.example.com/runs/${id}`,
|
||||||
});
|
});
|
||||||
expect(assertSuccessfulActionRuns([run(1, "success"), run(2, "success")], "chatgpt/feature", "current-sha")).toHaveLength(2);
|
expect(assertSuccessfulActionRuns([run(1, "success"), run(2, "success")], "chatgpt/feature", "current-sha")).toHaveLength(2);
|
||||||
expect(() => assertSuccessfulActionRuns([run(1, "success"), run(2, "failure")], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
|
expect(() => assertSuccessfulActionRuns([run(1, "success"), run(2, "failure")], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_FULL_VALIDATION_REQUIRED");
|
||||||
expect(() => assertSuccessfulActionRuns([run(1, "success", "stale-sha")], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
|
expect(() => assertSuccessfulActionRuns([run(1, "success", "stale-sha")], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -7,7 +7,7 @@ const branch = "codex/native-ci";
|
|||||||
const sha = "a".repeat(40);
|
const sha = "a".repeat(40);
|
||||||
const context = "CI / build-test (pull_request)";
|
const context = "CI / build-test (pull_request)";
|
||||||
const run = (id: number, overrides: Partial<GiteaActionRun> = {}): GiteaActionRun => ({
|
const run = (id: number, overrides: Partial<GiteaActionRun> = {}): GiteaActionRun => ({
|
||||||
id, run_number: id, display_title: "CI", path: ".gitea/workflows/ci.yml",
|
id, run_number: id, display_title: "Managed validation / full", path: ".gitea/workflows/managed-validation.yml",
|
||||||
event: "pull_request", head_sha: sha, head_branch: "", status: "completed",
|
event: "pull_request", head_sha: sha, head_branch: "", status: "completed",
|
||||||
conclusion: "success", html_url: `https://gitea.example/runs/${id}`, ...overrides,
|
conclusion: "success", html_url: `https://gitea.example/runs/${id}`, ...overrides,
|
||||||
});
|
});
|
||||||
@@ -25,11 +25,11 @@ describe("native PR validation discovery", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each(["pending", "queued", "waiting", "in_progress"])("does not authorize %s", (status) => {
|
it.each(["pending", "queued", "waiting", "in_progress"])("does not authorize %s", (status) => {
|
||||||
expect(() => assertSuccessfulActionRuns([run(1, { status, conclusion: undefined })], branch, sha)).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
|
expect(() => assertSuccessfulActionRuns([run(1, { status, conclusion: undefined })], branch, sha)).toThrow("FEATURE_DELIVERY_FULL_VALIDATION_REQUIRED");
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["cancelled", "canceled", "failure"])("does not authorize a %s job that never started", (conclusion) => {
|
it.each(["cancelled", "canceled", "failure"])("does not authorize a %s job that never started", (conclusion) => {
|
||||||
expect(() => assertSuccessfulActionRuns([run(1, { conclusion })], branch, sha)).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
|
expect(() => assertSuccessfulActionRuns([run(1, { conclusion })], branch, sha)).toThrow("FEATURE_DELIVERY_FULL_VALIDATION_REQUIRED");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the newest attempt of each workflow and preserves all evidence", () => {
|
it("uses the newest attempt of each workflow and preserves all evidence", () => {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Fastify from "fastify";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { loadEnv } from "../src/config/env.js";
|
||||||
|
import { registerOperatingSystemRoutes } from "../src/routes/operatingSystem.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({ updateWorkDependencies: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("../src/services/workGraph.js", () => ({
|
||||||
|
claimReadyWork: vi.fn(),
|
||||||
|
getProjectWorkGraph: vi.fn(),
|
||||||
|
listWorkGraphHistory: vi.fn(),
|
||||||
|
recordWorkCompletion: vi.fn(),
|
||||||
|
reclaimExpiredWork: vi.fn(),
|
||||||
|
releaseWork: vi.fn(),
|
||||||
|
updateWorkDependencies: mocks.updateWorkDependencies,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const env = loadEnv({
|
||||||
|
NODE_ENV: "test",
|
||||||
|
PROJECTBRAIN_DATA_SOURCE: "database",
|
||||||
|
DATABASE_URL: "postgresql://fixture:fixture@localhost:5432/projectbrain_fixture",
|
||||||
|
PROJECTBRAIN_OWNER_USERNAME: "fixture-owner",
|
||||||
|
PROJECTBRAIN_OWNER_PASSWORD: "fixture-only-owner-password",
|
||||||
|
PROJECTBRAIN_AUTH_SECRET: "fixture-only-auth-secret-with-at-least-32-characters",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("operating-system work-graph routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an active work-graph lease conflict to 409 with the authenticated actor", async () => {
|
||||||
|
mocks.updateWorkDependencies.mockRejectedValue(new Error("WORK_GRAPH_ACTIVE_LEASE"));
|
||||||
|
const app = Fastify();
|
||||||
|
app.decorateRequest("authUser", null);
|
||||||
|
app.addHook("onRequest", async (request) => {
|
||||||
|
request.authUser = {
|
||||||
|
id: "member-id",
|
||||||
|
username: "authenticated-member",
|
||||||
|
role: "member",
|
||||||
|
credentialVersion: 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await registerOperatingSystemRoutes(app, { env });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/api/projects/project-1/work-graph/node-1/dependencies",
|
||||||
|
payload: { dependencyNodeIds: ["node-2"], actor: "forged-caller" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(response.json().error.code).toBe("WORK_GRAPH_ACTIVE_LEASE");
|
||||||
|
expect(mocks.updateWorkDependencies).toHaveBeenCalledWith(
|
||||||
|
"project-1",
|
||||||
|
"node-1",
|
||||||
|
["node-2"],
|
||||||
|
"authenticated-member",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,9 +15,9 @@ const actionRun = (input: {
|
|||||||
}): GiteaActionRun => ({
|
}): GiteaActionRun => ({
|
||||||
id: input.id,
|
id: input.id,
|
||||||
run_number: input.id,
|
run_number: input.id,
|
||||||
display_title: input.workflow ?? "CI",
|
display_title: input.workflow ?? "Managed validation / full",
|
||||||
path: `.gitea/workflows/${input.workflow ?? "ci.yml"}`,
|
path: `.gitea/workflows/${input.workflow ?? "managed-validation.yml"}`,
|
||||||
event: "push",
|
event: input.workflow ? "pull_request" : "workflow_dispatch",
|
||||||
head_sha: input.headSha ?? "current-sha",
|
head_sha: input.headSha ?? "current-sha",
|
||||||
head_branch: "chatgpt/feature",
|
head_branch: "chatgpt/feature",
|
||||||
status: input.status ?? "completed",
|
status: input.status ?? "completed",
|
||||||
@@ -46,6 +46,7 @@ describe("review remediation regressions", () => {
|
|||||||
|
|
||||||
it("still fails closed when the latest run of any workflow is not successful", () => {
|
it("still fails closed when the latest run of any workflow is not successful", () => {
|
||||||
expect(() => assertSuccessfulActionRuns([
|
expect(() => assertSuccessfulActionRuns([
|
||||||
|
actionRun({ id: 9, conclusion: "success" }),
|
||||||
actionRun({ id: 10, conclusion: "success", workflow: "quality.yml" }),
|
actionRun({ id: 10, conclusion: "success", workflow: "quality.yml" }),
|
||||||
actionRun({ id: 11, conclusion: "failure", workflow: "security.yml" }),
|
actionRun({ id: 11, conclusion: "failure", workflow: "security.yml" }),
|
||||||
], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
|
], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
|
||||||
@@ -53,7 +54,7 @@ describe("review remediation regressions", () => {
|
|||||||
expect(() => assertSuccessfulActionRuns([
|
expect(() => assertSuccessfulActionRuns([
|
||||||
actionRun({ id: 12, conclusion: "success" }),
|
actionRun({ id: 12, conclusion: "success" }),
|
||||||
actionRun({ id: 13, conclusion: "", status: "in_progress" }),
|
actionRun({ id: 13, conclusion: "", status: "in_progress" }),
|
||||||
], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
|
], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_FULL_VALIDATION_REQUIRED");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects shipped placeholder credentials in production", () => {
|
it("rejects shipped placeholder credentials in production", () => {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { CompactRunner, CompactRunnerJob } from "../src/services/activityRuntime.js";
|
||||||
|
import { evaluateRunnerClaimHealth } from "../src/services/runnerClaimHealth.js";
|
||||||
|
|
||||||
|
const runner = (input: Partial<CompactRunner> = {}): CompactRunner => ({
|
||||||
|
id: 1,
|
||||||
|
name: "validation-1",
|
||||||
|
status: "online",
|
||||||
|
busy: false,
|
||||||
|
disabled: false,
|
||||||
|
labels: ["linux-validation"],
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
|
||||||
|
const job = (input: Partial<CompactRunnerJob> = {}): CompactRunnerJob => ({
|
||||||
|
id: 99,
|
||||||
|
runId: 9,
|
||||||
|
name: "full",
|
||||||
|
repository: "Jens/projectbrain",
|
||||||
|
branch: "feature",
|
||||||
|
status: "queued",
|
||||||
|
queueState: "runnable",
|
||||||
|
labels: ["linux-validation"],
|
||||||
|
completedSteps: 0,
|
||||||
|
totalSteps: 1,
|
||||||
|
url: "https://gitea.example/jobs/99",
|
||||||
|
createdAt: "2026-09-30T00:00:00.000Z",
|
||||||
|
runnableObservedSince: "2026-09-30T00:00:00.000Z",
|
||||||
|
claimableObservedSince: "2026-09-30T00:00:00.000Z",
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runner claim health", () => {
|
||||||
|
const now = Date.parse("2026-09-30T00:05:00.000Z");
|
||||||
|
|
||||||
|
it("marks an old queued job with a compatible idle runner as polling-stalled without inventing a restart action", () => {
|
||||||
|
const health = evaluateRunnerClaimHealth({ runners: [runner()], jobs: [job()], generatedAt: "2026-09-30T00:05:00.000Z" }, { now, stallSeconds: 120 });
|
||||||
|
expect(health).toMatchObject({
|
||||||
|
status: "stalled",
|
||||||
|
recoveryOwner: "appops-runner-fleet",
|
||||||
|
recoveryPolicy: "controller-owned",
|
||||||
|
counts: { pollingStalled: 1 },
|
||||||
|
oldestWaitSeconds: 300,
|
||||||
|
oldestClaimableWaitSeconds: 300,
|
||||||
|
});
|
||||||
|
expect(health.jobs[0]).toMatchObject({ state: "polling-stalled", compatibleIdleRunnerNames: ["validation-1"] });
|
||||||
|
expect(JSON.stringify(health)).not.toContain("container.restart");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies a busy compatible runner as capacity-wait", () => {
|
||||||
|
const health = evaluateRunnerClaimHealth({ runners: [runner({ busy: true })], jobs: [job()], generatedAt: "2026-09-30T00:05:00.000Z" }, { now });
|
||||||
|
expect(health.jobs[0]).toMatchObject({ state: "capacity-wait", waitSeconds: 300, claimableWaitSeconds: 0 });
|
||||||
|
expect(health.oldestClaimableWaitSeconds).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a label block when no online runner can satisfy the job", () => {
|
||||||
|
const health = evaluateRunnerClaimHealth({ runners: [runner({ labels: ["security"] })], jobs: [job()], generatedAt: "2026-09-30T00:05:00.000Z" }, { now });
|
||||||
|
expect(health.status).toBe("degraded");
|
||||||
|
expect(health.counts.labelBlocked).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a polling stall independently of how many compatible idle runners exist", () => {
|
||||||
|
const health = evaluateRunnerClaimHealth({
|
||||||
|
runners: [runner(), runner({ id: 2, name: "validation-2" })],
|
||||||
|
jobs: [job()],
|
||||||
|
generatedAt: "2026-09-30T00:05:00.000Z",
|
||||||
|
}, { now });
|
||||||
|
expect(health.counts.pollingStalled).toBe(1);
|
||||||
|
expect(health.jobs[0].compatibleIdleRunnerNames).toEqual(["validation-1", "validation-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a newly queued compatible job inside the normal claim grace window", () => {
|
||||||
|
const health = evaluateRunnerClaimHealth({
|
||||||
|
runners: [runner()],
|
||||||
|
jobs: [job({
|
||||||
|
createdAt: "2026-09-30T00:04:30.000Z",
|
||||||
|
runnableObservedSince: "2026-09-30T00:04:30.000Z",
|
||||||
|
claimableObservedSince: "2026-09-30T00:04:30.000Z",
|
||||||
|
})],
|
||||||
|
generatedAt: "2026-09-30T00:05:00.000Z",
|
||||||
|
}, { now, stallSeconds: 120 });
|
||||||
|
expect(health.status).toBe("healthy");
|
||||||
|
expect(health.jobs[0].state).toBe("claimable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports incomplete coverage rather than healthy when queued jobs were omitted from the scan", () => {
|
||||||
|
const health = evaluateRunnerClaimHealth({
|
||||||
|
availability: "live",
|
||||||
|
runners: [runner()],
|
||||||
|
jobs: Array.from({ length: 50 }, (_, index) => job({ id: index + 1, createdAt: "2026-09-30T00:04:30.000Z" })),
|
||||||
|
runnableJobScan: { complete: false, observed: 50, total: 60 },
|
||||||
|
generatedAt: "2026-09-30T00:05:00.000Z",
|
||||||
|
}, { now });
|
||||||
|
|
||||||
|
expect(health).toMatchObject({
|
||||||
|
status: "incomplete",
|
||||||
|
coverage: { complete: false, observed: 50, total: 60 },
|
||||||
|
counts: { queued: 50, pollingStalled: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,6 +52,7 @@ services:
|
|||||||
PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES: ${PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES:-true}
|
PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES: ${PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES:-true}
|
||||||
PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS: ${PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS:-60}
|
PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS: ${PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS:-60}
|
||||||
PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS: ${PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS:-5400}
|
PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS: ${PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS:-5400}
|
||||||
|
PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS: ${PROJECTBRAIN_RUNNER_CLAIM_STALL_SECONDS:-120}
|
||||||
PROJECTBRAIN_HUB_SERVICE_TOKEN: ${PROJECTBRAIN_HUB_SERVICE_TOKEN:-}
|
PROJECTBRAIN_HUB_SERVICE_TOKEN: ${PROJECTBRAIN_HUB_SERVICE_TOKEN:-}
|
||||||
PROJECTBRAIN_PUBLIC_URL: ${PROJECTBRAIN_PUBLIC_URL:-}
|
PROJECTBRAIN_PUBLIC_URL: ${PROJECTBRAIN_PUBLIC_URL:-}
|
||||||
PROJECTBRAIN_ENTRA_TENANT_ID: ${PROJECTBRAIN_ENTRA_TENANT_ID:-}
|
PROJECTBRAIN_ENTRA_TENANT_ID: ${PROJECTBRAIN_ENTRA_TENANT_ID:-}
|
||||||
|
|||||||
@@ -39,6 +39,105 @@ const autoModeState = (): AutoModeState => ({
|
|||||||
updatedBy: "owner",
|
updatedBy: "owner",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const queueState = (manualOrder = false): AutoModeState => {
|
||||||
|
const base = autoModeState();
|
||||||
|
const roadmap = base.roadmap!;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
roadmap: {
|
||||||
|
...roadmap,
|
||||||
|
phases: roadmap.phases.map((phase) => ({
|
||||||
|
...phase,
|
||||||
|
items: [
|
||||||
|
{ id: "item-a", title: "Item A", status: "pending", attempts: 0, ...(manualOrder ? { manualOrder: 2 } : {}) },
|
||||||
|
{ id: "item-b", title: "Item B", status: "pending", attempts: 0, ...(manualOrder ? { manualOrder: 1 } : {}) },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectQueueOrder = async (page: import("@playwright/test").Page, firstTitle: string) => {
|
||||||
|
const rows = page.locator('section[aria-labelledby="auto-queue-order-heading"] > div.space-y-2 > div');
|
||||||
|
await expect(rows).toHaveCount(2);
|
||||||
|
await expect(rows.nth(0)).toContainText(firstTitle);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exerciseStaleQueueRead = async (
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
mutation: "reorder" | "reset",
|
||||||
|
) => {
|
||||||
|
const beforeMutation = queueState(mutation === "reset");
|
||||||
|
let persistedState = beforeMutation;
|
||||||
|
let getCount = 0;
|
||||||
|
let releaseStaleGet!: () => void;
|
||||||
|
let markStaleGetStarted!: () => void;
|
||||||
|
let releaseMutation!: () => void;
|
||||||
|
let markMutationStarted!: () => void;
|
||||||
|
const staleGetGate = new Promise<void>((resolve) => { releaseStaleGet = resolve; });
|
||||||
|
const staleGetStarted = new Promise<void>((resolve) => { markStaleGetStarted = resolve; });
|
||||||
|
const mutationGate = new Promise<void>((resolve) => { releaseMutation = resolve; });
|
||||||
|
const mutationStarted = new Promise<void>((resolve) => { markMutationStarted = resolve; });
|
||||||
|
|
||||||
|
await page.route(`**/api/projects/${projectId}/autonomy**`, async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
if (request.method() === "GET") {
|
||||||
|
getCount += 1;
|
||||||
|
if (getCount === 2) {
|
||||||
|
markStaleGetStarted();
|
||||||
|
await staleGetGate;
|
||||||
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(beforeMutation)) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(persistedState)) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mutation === "reorder" && request.method() === "PUT") {
|
||||||
|
const { itemIds } = request.postDataJSON() as { itemIds: string[] };
|
||||||
|
markMutationStarted();
|
||||||
|
await mutationGate;
|
||||||
|
const positions = new Map(itemIds.map((id, index) => [id, index]));
|
||||||
|
persistedState = {
|
||||||
|
...beforeMutation,
|
||||||
|
roadmap: {
|
||||||
|
...beforeMutation.roadmap!,
|
||||||
|
phases: beforeMutation.roadmap!.phases.map((phase) => ({
|
||||||
|
...phase,
|
||||||
|
items: phase.items.map((item) => positions.has(item.id)
|
||||||
|
? { ...item, manualOrder: positions.get(item.id) }
|
||||||
|
: item),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (mutation === "reset" && request.method() === "DELETE") {
|
||||||
|
markMutationStarted();
|
||||||
|
await mutationGate;
|
||||||
|
persistedState = queueState();
|
||||||
|
}
|
||||||
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(persistedState)) });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/projects/${projectId}/auto`);
|
||||||
|
await expect(page.getByRole("heading", { name: "Auto Mode", exact: true })).toBeVisible();
|
||||||
|
await page.getByText("Roadmap en wachtrij", { exact: true }).click();
|
||||||
|
await expectQueueOrder(page, mutation === "reset" ? "Item B" : "Item A");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Vernieuwen", exact: true }).click();
|
||||||
|
await staleGetStarted;
|
||||||
|
if (mutation === "reorder") {
|
||||||
|
await page.getByRole("button", { name: "Item B omhoog" }).click();
|
||||||
|
} else {
|
||||||
|
await page.getByRole("button", { name: "Automatische volgorde herstellen" }).click();
|
||||||
|
}
|
||||||
|
await mutationStarted;
|
||||||
|
await expect(page.getByText("Opslaan…", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
releaseMutation();
|
||||||
|
await expectQueueOrder(page, mutation === "reset" ? "Item A" : "Item B");
|
||||||
|
releaseStaleGet();
|
||||||
|
await expectQueueOrder(page, mutation === "reset" ? "Item A" : "Item B");
|
||||||
|
};
|
||||||
|
|
||||||
test("attention inbox exposes every visible item as an actionable destination", async ({ page }) => {
|
test("attention inbox exposes every visible item as an actionable destination", async ({ page }) => {
|
||||||
await page.goto(`/projects/${projectId}/attention`);
|
await page.goto(`/projects/${projectId}/attention`);
|
||||||
await expect(page.getByRole("heading", { name: "Jouw aandacht", exact: true })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Jouw aandacht", exact: true })).toBeVisible();
|
||||||
@@ -110,3 +209,11 @@ test("Auto Mode distinguishes queued work from real execution and protects roadm
|
|||||||
const accessibility = await new AxeBuilder({ page }).analyze();
|
const accessibility = await new AxeBuilder({ page }).analyze();
|
||||||
expect(accessibility.violations.filter((violation) => ["serious", "critical"].includes(violation.impact ?? ""))).toEqual([]);
|
expect(accessibility.violations.filter((violation) => ["serious", "critical"].includes(violation.impact ?? ""))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Auto Mode ignores an older read after saving a reordered queue", async ({ page }) => {
|
||||||
|
await exerciseStaleQueueRead(page, "reorder");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Auto Mode ignores an older read after resetting queue order", async ({ page }) => {
|
||||||
|
await exerciseStaleQueueRead(page, "reset");
|
||||||
|
});
|
||||||
Generated
+16
-15
@@ -9,8 +9,9 @@ overrides:
|
|||||||
esbuild: 0.28.2
|
esbuild: 0.28.2
|
||||||
hono: ^4.13.5
|
hono: ^4.13.5
|
||||||
qs: ^6.16.0
|
qs: ^6.16.0
|
||||||
fast-uri@>=3.0.0 <4.0.0: 3.1.7
|
fast-uri@>=3.0.0 <4.0.0: 3.1.8
|
||||||
fast-uri@>=4.0.0 <5.0.0: 4.1.4
|
fast-uri@>=4.0.0 <5.0.0: 4.1.5
|
||||||
|
ip-address@>=10.0.0 <11.0.0: 10.7.2
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
@@ -2156,11 +2157,11 @@ packages:
|
|||||||
fast-querystring@1.1.2:
|
fast-querystring@1.1.2:
|
||||||
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
|
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
|
||||||
|
|
||||||
fast-uri@3.1.7:
|
fast-uri@3.1.8:
|
||||||
resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==}
|
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
|
||||||
|
|
||||||
fast-uri@4.1.4:
|
fast-uri@4.1.5:
|
||||||
resolution: {integrity: sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==}
|
resolution: {integrity: sha512-vZeoMRB4epNr7QfdHxel7te/RcX16CxyXI07JCCTFWZA2s4v1azGNESRj+2EoaHSaWFL/Z3GmKT2jF6A202jLg==}
|
||||||
|
|
||||||
fastify-plugin@5.1.0:
|
fastify-plugin@5.1.0:
|
||||||
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||||
@@ -2349,8 +2350,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
ip-address@10.5.0:
|
ip-address@10.7.2:
|
||||||
resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==}
|
resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
ipaddr.js@1.9.1:
|
ipaddr.js@1.9.1:
|
||||||
@@ -3654,7 +3655,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ajv: 8.20.0
|
ajv: 8.20.0
|
||||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||||
fast-uri: 3.1.7
|
fast-uri: 3.1.8
|
||||||
|
|
||||||
'@fastify/cookie@11.1.2':
|
'@fastify/cookie@11.1.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4808,7 +4809,7 @@ snapshots:
|
|||||||
ajv@8.20.0:
|
ajv@8.20.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
fast-uri: 3.1.7
|
fast-uri: 3.1.8
|
||||||
json-schema-traverse: 1.0.0
|
json-schema-traverse: 1.0.0
|
||||||
require-from-string: 2.0.2
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
@@ -5235,7 +5236,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
express: 5.2.1
|
express: 5.2.1
|
||||||
ip-address: 10.5.0
|
ip-address: 10.7.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -5291,7 +5292,7 @@ snapshots:
|
|||||||
'@fastify/merge-json-schemas': 0.2.1
|
'@fastify/merge-json-schemas': 0.2.1
|
||||||
ajv: 8.20.0
|
ajv: 8.20.0
|
||||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||||
fast-uri: 4.1.4
|
fast-uri: 4.1.5
|
||||||
json-schema-ref-resolver: 3.0.0
|
json-schema-ref-resolver: 3.0.0
|
||||||
rfdc: 1.4.1
|
rfdc: 1.4.1
|
||||||
|
|
||||||
@@ -5301,9 +5302,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
fast-decode-uri-component: 1.0.1
|
fast-decode-uri-component: 1.0.1
|
||||||
|
|
||||||
fast-uri@3.1.7: {}
|
fast-uri@3.1.8: {}
|
||||||
|
|
||||||
fast-uri@4.1.4: {}
|
fast-uri@4.1.5: {}
|
||||||
|
|
||||||
fastify-plugin@5.1.0: {}
|
fastify-plugin@5.1.0: {}
|
||||||
|
|
||||||
@@ -5491,7 +5492,7 @@ snapshots:
|
|||||||
|
|
||||||
internmap@2.0.3: {}
|
internmap@2.0.3: {}
|
||||||
|
|
||||||
ip-address@10.5.0: {}
|
ip-address@10.7.2: {}
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -20,8 +20,9 @@ overrides:
|
|||||||
esbuild: 0.28.2
|
esbuild: 0.28.2
|
||||||
hono: ^4.13.5
|
hono: ^4.13.5
|
||||||
qs: ^6.16.0
|
qs: ^6.16.0
|
||||||
'fast-uri@>=3.0.0 <4.0.0': 3.1.7
|
'fast-uri@>=3.0.0 <4.0.0': 3.1.8
|
||||||
'fast-uri@>=4.0.0 <5.0.0': 4.1.4
|
'fast-uri@>=4.0.0 <5.0.0': 4.1.5
|
||||||
|
'ip-address@>=10.0.0 <11.0.0': 10.7.2
|
||||||
|
|
||||||
peerDependencyRules:
|
peerDependencyRules:
|
||||||
allowedVersions:
|
allowedVersions:
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { ArrowDown, ArrowUp, GripVertical, LockKeyhole } from "lucide-react";
|
||||||
|
import { Badge, Button } from "@/components/ui";
|
||||||
|
import type { AutoModeItem } from "@/shared/api/autonomyApi";
|
||||||
|
|
||||||
|
export type SortableQueueEntry = {
|
||||||
|
item: AutoModeItem;
|
||||||
|
phaseTitle: string;
|
||||||
|
blockedReason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SortableWorkQueue = ({
|
||||||
|
entries,
|
||||||
|
saving,
|
||||||
|
manualOrdering,
|
||||||
|
onReorder,
|
||||||
|
onReset,
|
||||||
|
}: {
|
||||||
|
entries: SortableQueueEntry[];
|
||||||
|
saving: boolean;
|
||||||
|
manualOrdering: boolean;
|
||||||
|
onReorder: (itemIds: string[]) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}) => {
|
||||||
|
const [draggedId, setDraggedId] = React.useState<string>();
|
||||||
|
const [dragOverId, setDragOverId] = React.useState<string>();
|
||||||
|
|
||||||
|
const move = React.useCallback((itemId: string, targetIndex: number) => {
|
||||||
|
const currentIndex = entries.findIndex((entry) => entry.item.id === itemId);
|
||||||
|
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= entries.length || currentIndex === targetIndex || saving) return;
|
||||||
|
const ids = entries.map((entry) => entry.item.id);
|
||||||
|
const [moved] = ids.splice(currentIndex, 1);
|
||||||
|
ids.splice(targetIndex, 0, moved);
|
||||||
|
onReorder(ids);
|
||||||
|
}, [entries, onReorder, saving]);
|
||||||
|
|
||||||
|
if (!entries.length) {
|
||||||
|
return <div className="rounded-lg border border-dashed bg-muted/20 p-3 text-sm text-muted-foreground">Geen pending committed werk om te prioriteren.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <section className="space-y-2" aria-labelledby="auto-queue-order-heading">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 id="auto-queue-order-heading" className="text-sm font-semibold">Uitvoeringsvolgorde</h3>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">{manualOrdering ? "Dit is de expliciete uitvoeringsvolgorde voor pending werk; dependencies blijven altijd een harde grens." : "Dit toont de roadmapvolgorde. ProjectBrain kiest automatisch de beste veilige volgende stap; sleep een item om een expliciete volgorde vast te leggen."}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{manualOrdering ? <Button type="button" variant="outline" size="sm" disabled={saving} onClick={onReset}>Automatische volgorde herstellen</Button> : null}
|
||||||
|
<Badge variant={saving ? "secondary" : manualOrdering ? "secondary" : "outline"}>{saving ? "Opslaan…" : manualOrdering ? "Handmatige volgorde" : `${entries.length} in wachtrij`}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entries.map((entry, index) => {
|
||||||
|
const isDragged = draggedId === entry.item.id;
|
||||||
|
const isOver = dragOverId === entry.item.id && draggedId !== entry.item.id;
|
||||||
|
return <div
|
||||||
|
key={entry.item.id}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (!draggedId || saving) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
setDragOverId(entry.item.id);
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!draggedId || saving) return;
|
||||||
|
move(draggedId, index);
|
||||||
|
setDraggedId(undefined);
|
||||||
|
setDragOverId(undefined);
|
||||||
|
}}
|
||||||
|
className={`flex min-w-0 items-center gap-2 rounded-lg border bg-background p-2 transition ${isDragged ? "opacity-50" : ""} ${isOver ? "border-primary ring-1 ring-primary/30" : ""}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
draggable={!saving}
|
||||||
|
disabled={saving}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
setDraggedId(entry.item.id);
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/plain", entry.item.id);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => { setDraggedId(undefined); setDragOverId(undefined); }}
|
||||||
|
className="flex h-9 w-9 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 active:cursor-grabbing"
|
||||||
|
aria-label={`Versleep ${entry.item.title}`}
|
||||||
|
title="Vastnemen en verslepen"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span className="w-6 shrink-0 text-center text-xs font-semibold tabular-nums text-muted-foreground">{index + 1}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<p className="min-w-0 break-words text-sm font-medium [overflow-wrap:anywhere]">{entry.item.title}</p>
|
||||||
|
{entry.blockedReason ? <Badge variant="outline" className="gap-1 text-[10px]"><LockKeyhole className="h-3 w-3" />{entry.blockedReason}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">{entry.phaseTitle}{entry.item.source ? ` · ${entry.item.source.replaceAll("_", " ")}` : ""}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button type="button" variant="ghost" size="icon" disabled={saving || index === 0} onClick={() => move(entry.item.id, index - 1)} aria-label={`${entry.item.title} omhoog`} title="Omhoog"><ArrowUp className="h-4 w-4" /></Button>
|
||||||
|
<Button type="button" variant="ghost" size="icon" disabled={saving || index === entries.length - 1} onClick={() => move(entry.item.id, index + 1)} aria-label={`${entry.item.title} omlaag`} title="Omlaag"><ArrowDown className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>;
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
import { ArrowRight, Bot, BrainCircuit, CheckCircle2, ListTodo, RefreshCw, RotateCcw, ShieldAlert, Upload } from "lucide-react";
|
import { ArrowRight, Bot, BrainCircuit, CheckCircle2, ListTodo, RefreshCw, RotateCcw, ShieldAlert, Upload } from "lucide-react";
|
||||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Progress, Switch, Textarea } from "@/components/ui";
|
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Progress, Switch, Textarea } from "@/components/ui";
|
||||||
import { autonomyApi, type AutoModePhase, type AutoModeState } from "@/shared/api/autonomyApi";
|
import { autonomyApi, type AutoModePhase, type AutoModeState } from "@/shared/api/autonomyApi";
|
||||||
|
import { SortableWorkQueue } from "@/features/autonomy/components/SortableWorkQueue";
|
||||||
import { ApiError } from "@/shared/api/httpClient";
|
import { ApiError } from "@/shared/api/httpClient";
|
||||||
import { autoModeItemLabel, autoModePhaseLabel, autoModeStatusLabel, getAutoModeOperationalState } from "@/shared/lib/autoModePresentation";
|
import { autoModeItemLabel, autoModePhaseLabel, autoModeStatusLabel, getAutoModeOperationalState } from "@/shared/lib/autoModePresentation";
|
||||||
import { autonomyProfileDefinitions, autonomyProfileOrder } from "@/shared/lib/autonomyProfiles";
|
import { autonomyProfileDefinitions, autonomyProfileOrder } from "@/shared/lib/autonomyProfiles";
|
||||||
@@ -19,6 +20,7 @@ const AutoMode = () => {
|
|||||||
const [state, setState] = React.useState<AutoModeState>();
|
const [state, setState] = React.useState<AutoModeState>();
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
const [queueSaving, setQueueSaving] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string>();
|
const [error, setError] = React.useState<string>();
|
||||||
const [roadmapTitle, setRoadmapTitle] = React.useState("");
|
const [roadmapTitle, setRoadmapTitle] = React.useState("");
|
||||||
const [roadmapMarkdown, setRoadmapMarkdown] = React.useState("");
|
const [roadmapMarkdown, setRoadmapMarkdown] = React.useState("");
|
||||||
@@ -71,25 +73,111 @@ const AutoMode = () => {
|
|||||||
};
|
};
|
||||||
const savePolicy = async () => { if (await configure(policy)) showSuccess("Autonomiebeleid opgeslagen"); };
|
const savePolicy = async () => { if (await configure(policy)) showSuccess("Autonomiebeleid opgeslagen"); };
|
||||||
|
|
||||||
const phases = state?.roadmap?.phases ?? [];
|
const phases = React.useMemo(() => state?.roadmap?.phases ?? [], [state?.roadmap?.phases]);
|
||||||
const items = phases.flatMap((phase) => phase.items);
|
const items = React.useMemo(() => phases.flatMap((phase) => phase.items), [phases]);
|
||||||
const completedItems = items.filter((item) => item.status === "completed").length;
|
const completedItems = items.filter((item) => item.status === "completed").length;
|
||||||
const openItems = items.filter((item) => item.status !== "completed");
|
const openItems = items.filter((item) => item.status !== "completed");
|
||||||
const progress = items.length ? Math.round((completedItems / items.length) * 100) : state?.status === "completed" ? 100 : 0;
|
const progress = items.length ? Math.round((completedItems / items.length) * 100) : state?.status === "completed" ? 100 : 0;
|
||||||
const activePhase = state?.roadmap ? phases[state.roadmap.currentPhaseIndex] : undefined;
|
const activePhase = state?.roadmap ? phases[state.roadmap.currentPhaseIndex] : undefined;
|
||||||
const inProgressItem = activePhase?.items.find((item) => item.status === "in_progress");
|
const inProgressItem = activePhase?.items.find((item) => item.status === "in_progress");
|
||||||
const activeItem = inProgressItem ?? activePhase?.items.find((item) => item.status !== "completed");
|
const queueEntries = React.useMemo(() => {
|
||||||
const nextItem = phases.flatMap((phase) => phase.items).find((item) => item.status === "pending");
|
const completedIds = new Set(items.filter((item) => item.status === "completed").map((item) => item.id));
|
||||||
|
return phases.flatMap((phase, phaseIndex) => phase.items.map((item, itemIndex) => ({
|
||||||
|
item,
|
||||||
|
phaseTitle: phase.title,
|
||||||
|
phaseIndex,
|
||||||
|
itemIndex,
|
||||||
|
blockedReason: item.waitingExternal
|
||||||
|
? "wacht extern"
|
||||||
|
: (item.dependsOn ?? []).some((dependencyId) => !completedIds.has(dependencyId))
|
||||||
|
? "dependency"
|
||||||
|
: undefined,
|
||||||
|
})))
|
||||||
|
.filter((entry) => entry.item.status === "pending" && entry.item.scope !== "future" && entry.item.scope !== "stretch")
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftOrder = left.item.manualOrder;
|
||||||
|
const rightOrder = right.item.manualOrder;
|
||||||
|
if (typeof leftOrder === "number" || typeof rightOrder === "number") {
|
||||||
|
if (typeof leftOrder !== "number") return 1;
|
||||||
|
if (typeof rightOrder !== "number") return -1;
|
||||||
|
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
||||||
|
}
|
||||||
|
return left.phaseIndex - right.phaseIndex || left.itemIndex - right.itemIndex;
|
||||||
|
});
|
||||||
|
}, [items, phases]);
|
||||||
|
const queueHasManualOrder = queueEntries.some((entry) => typeof entry.item.manualOrder === "number");
|
||||||
|
const nextItem = queueHasManualOrder ? queueEntries.find((entry) => !entry.blockedReason)?.item : undefined;
|
||||||
const operational = state ? getAutoModeOperationalState(state) : undefined;
|
const operational = state ? getAutoModeOperationalState(state) : undefined;
|
||||||
const policyDirty = Boolean(state && (state.profile !== policy.profile || state.autoBrainstorm !== policy.autoBrainstorm || state.maxBrainstormIterations !== policy.maxBrainstormIterations || state.maxConsecutiveFailures !== policy.maxConsecutiveFailures || state.leaseMinutes !== policy.leaseMinutes || state.maxWorkItemsPerRun !== policy.maxWorkItemsPerRun || state.maxRunMinutes !== policy.maxRunMinutes || state.maxRecoveryMinutes !== policy.maxRecoveryMinutes));
|
const policyDirty = Boolean(state && (state.profile !== policy.profile || state.autoBrainstorm !== policy.autoBrainstorm || state.maxBrainstormIterations !== policy.maxBrainstormIterations || state.maxConsecutiveFailures !== policy.maxConsecutiveFailures || state.leaseMinutes !== policy.leaseMinutes || state.maxWorkItemsPerRun !== policy.maxWorkItemsPerRun || state.maxRunMinutes !== policy.maxRunMinutes || state.maxRecoveryMinutes !== policy.maxRecoveryMinutes));
|
||||||
const updatePolicyNumber = (key: "maxBrainstormIterations" | "maxConsecutiveFailures" | "leaseMinutes" | "maxWorkItemsPerRun" | "maxRunMinutes" | "maxRecoveryMinutes", value: string, minimum: number, maximum: number) => { const parsed = Number(value); if (Number.isFinite(parsed)) setPolicy((current) => ({ ...current, profile: "custom", [key]: Math.max(minimum, Math.min(maximum, Math.trunc(parsed))) })); };
|
const updatePolicyNumber = (key: "maxBrainstormIterations" | "maxConsecutiveFailures" | "leaseMinutes" | "maxWorkItemsPerRun" | "maxRunMinutes" | "maxRecoveryMinutes", value: string, minimum: number, maximum: number) => { const parsed = Number(value); if (Number.isFinite(parsed)) setPolicy((current) => ({ ...current, profile: "custom", [key]: Math.max(minimum, Math.min(maximum, Math.trunc(parsed))) })); };
|
||||||
const selectProfile = (profile: AutoModeState["profile"]) => { const settings = autonomyProfileDefinitions[profile].settings; setPolicy((current) => settings ? ({ ...current, profile, ...settings }) : ({ ...current, profile: "custom" })); };
|
const selectProfile = (profile: AutoModeState["profile"]) => { const settings = autonomyProfileDefinitions[profile].settings; setPolicy((current) => settings ? ({ ...current, profile, ...settings }) : ({ ...current, profile: "custom" })); };
|
||||||
|
|
||||||
|
const reorderQueue = async (itemIds: string[]) => {
|
||||||
|
if (!projectId || !state?.roadmap || queueSaving) return;
|
||||||
|
const previous = state;
|
||||||
|
const positions = new Map(itemIds.map((id, index) => [id, index]));
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
roadmap: {
|
||||||
|
...state.roadmap,
|
||||||
|
phases: state.roadmap.phases.map((phase) => ({
|
||||||
|
...phase,
|
||||||
|
items: phase.items.map((item) => positions.has(item.id) ? { ...item, manualOrder: positions.get(item.id) } : item),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setQueueSaving(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const next = await autonomyApi.reorderQueue(projectId, itemIds);
|
||||||
|
loadGeneration.current += 1;
|
||||||
|
setLoading(false);
|
||||||
|
setState(next);
|
||||||
|
showSuccess("Wachtrijvolgorde opgeslagen");
|
||||||
|
} catch (nextError) {
|
||||||
|
setState(previous);
|
||||||
|
setError(readableError(nextError));
|
||||||
|
void load(true);
|
||||||
|
} finally {
|
||||||
|
setQueueSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetQueueOrder = async () => {
|
||||||
|
if (!projectId || !state?.roadmap || queueSaving) return;
|
||||||
|
const previous = state;
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
roadmap: {
|
||||||
|
...state.roadmap,
|
||||||
|
phases: state.roadmap.phases.map((phase) => ({
|
||||||
|
...phase,
|
||||||
|
items: phase.items.map((item) => ({ ...item, manualOrder: undefined })),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setQueueSaving(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const next = await autonomyApi.resetQueueOrder(projectId);
|
||||||
|
loadGeneration.current += 1;
|
||||||
|
setLoading(false);
|
||||||
|
setState(next);
|
||||||
|
showSuccess("Automatische prioritering hersteld");
|
||||||
|
} catch (nextError) {
|
||||||
|
setState(previous);
|
||||||
|
setError(readableError(nextError));
|
||||||
|
void load(true);
|
||||||
|
} finally {
|
||||||
|
setQueueSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
const interval = window.setInterval(() => { if (document.visibilityState === "visible" && !saving && !policyDirty) void load(true); }, 10_000);
|
const interval = window.setInterval(() => { if (document.visibilityState === "visible" && !saving && !queueSaving && !policyDirty) void load(true); }, 10_000);
|
||||||
return () => window.clearInterval(interval);
|
return () => window.clearInterval(interval);
|
||||||
}, [load, policyDirty, projectId, saving]);
|
}, [load, policyDirty, projectId, queueSaving, saving]);
|
||||||
|
|
||||||
if (loading && !state) return <div className="rounded-xl border bg-card p-6 text-sm text-muted-foreground">Auto Mode laden…</div>;
|
if (loading && !state) return <div className="rounded-xl border bg-card p-6 text-sm text-muted-foreground">Auto Mode laden…</div>;
|
||||||
const controllerClass = operational?.tone === "blocked" ? "border-destructive/40" : operational?.tone === "retry" ? "border-amber-300" : operational?.tone === "active" ? "border-primary/50" : operational?.tone === "complete" ? "border-emerald-300" : "border-dashed";
|
const controllerClass = operational?.tone === "blocked" ? "border-destructive/40" : operational?.tone === "retry" ? "border-amber-300" : operational?.tone === "active" ? "border-primary/50" : operational?.tone === "complete" ? "border-emerald-300" : "border-dashed";
|
||||||
@@ -104,7 +192,7 @@ const AutoMode = () => {
|
|||||||
<div className="flex min-w-0 gap-3"><span className="shrink-0 rounded-full bg-muted p-2">{operational.tone === "blocked" ? <ShieldAlert className="h-5 w-5 text-destructive" /> : operational.tone === "retry" ? <RefreshCw className="h-5 w-5 text-amber-700" /> : <Bot className="h-5 w-5" />}</span><div className="min-w-0"><div className="flex flex-wrap gap-2"><Badge variant="outline" className="max-w-full whitespace-normal [overflow-wrap:anywhere]">{operational.label}</Badge><Badge variant={state.enabled ? "default" : "outline"} className="max-w-full whitespace-normal [overflow-wrap:anywhere]">Auto {state.enabled ? "aan" : "uit"}</Badge></div><h2 className="mt-2 text-xl font-semibold">{operational.headline}</h2><p className="mt-1 max-w-3xl text-sm leading-6 text-muted-foreground">{operational.detail}</p></div></div>
|
<div className="flex min-w-0 gap-3"><span className="shrink-0 rounded-full bg-muted p-2">{operational.tone === "blocked" ? <ShieldAlert className="h-5 w-5 text-destructive" /> : operational.tone === "retry" ? <RefreshCw className="h-5 w-5 text-amber-700" /> : <Bot className="h-5 w-5" />}</span><div className="min-w-0"><div className="flex flex-wrap gap-2"><Badge variant="outline" className="max-w-full whitespace-normal [overflow-wrap:anywhere]">{operational.label}</Badge><Badge variant={state.enabled ? "default" : "outline"} className="max-w-full whitespace-normal [overflow-wrap:anywhere]">Auto {state.enabled ? "aan" : "uit"}</Badge></div><h2 className="mt-2 text-xl font-semibold">{operational.headline}</h2><p className="mt-1 max-w-3xl text-sm leading-6 text-muted-foreground">{operational.detail}</p></div></div>
|
||||||
<div className="flex max-w-full flex-wrap gap-2"><div className="flex items-center gap-3 rounded-lg border px-4 py-3"><Label htmlFor="auto-mode-toggle" className="font-semibold">Auto</Label><Switch id="auto-mode-toggle" checked={state.enabled} disabled={saving} onCheckedChange={(enabled) => void configure({ enabled })} /></div>{state.enabled ? <Button variant="outline" onClick={() => void configure({ pauseAfterCurrent: true })} disabled={saving || state.pauseAfterCurrent}>{state.pauseAfterCurrent ? "Pauze gepland" : "Pauzeer na huidig werk"}</Button> : null}</div>
|
<div className="flex max-w-full flex-wrap gap-2"><div className="flex items-center gap-3 rounded-lg border px-4 py-3"><Label htmlFor="auto-mode-toggle" className="font-semibold">Auto</Label><Switch id="auto-mode-toggle" checked={state.enabled} disabled={saving} onCheckedChange={(enabled) => void configure({ enabled })} /></div>{state.enabled ? <Button variant="outline" onClick={() => void configure({ pauseAfterCurrent: true })} disabled={saving || state.pauseAfterCurrent}>{state.pauseAfterCurrent ? "Pauze gepland" : "Pauzeer na huidig werk"}</Button> : null}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 md:grid-cols-4"><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Nu</p><p className="mt-1 font-semibold">{inProgressItem ? inProgressItem.title : state.activeLease ? "Compatibiliteitslease actief" : state.enabled ? "Wacht op Gitea-branch" : "Auto staat uit"}</p></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Hierna</p><p className="mt-1 text-sm">{nextItem?.title ?? (openItems.length ? activePhase?.title ?? "Volgende veilige stap wordt bepaald" : "Geen open committed werk")}</p></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Voortgang</p><p className="mt-1 font-semibold">{completedItems}/{items.length} · {progress}%</p><Progress className="mt-2 h-1.5" value={progress} aria-valuetext={items.length ? `${completedItems} van ${items.length} werkitems voltooid (${progress}%)` : `${progress}%`} /></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Open</p><p className="mt-1 font-semibold">{openItems.length} werkitem{openItems.length === 1 ? "" : "s"}</p><p className="mt-1 text-xs text-muted-foreground">{activePhase ? `Fase: ${activePhase.title}` : "Geen actieve fase"}</p></div></div>
|
<div className="grid gap-3 md:grid-cols-4"><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Nu</p><p className="mt-1 font-semibold">{inProgressItem ? inProgressItem.title : state.activeLease ? "Compatibiliteitslease actief" : state.enabled ? "Wacht op Gitea-branch" : "Auto staat uit"}</p></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Hierna</p><p className="mt-1 text-sm">{nextItem?.title ?? (openItems.length ? queueHasManualOrder ? activePhase?.title ?? "Volgende handmatige stap wordt bepaald" : "Slimme scheduler bepaalt de volgende veilige stap" : "Geen open committed werk")}</p></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Voortgang</p><p className="mt-1 font-semibold">{completedItems}/{items.length} · {progress}%</p><Progress className="mt-2 h-1.5" value={progress} aria-valuetext={items.length ? `${completedItems} van ${items.length} werkitems voltooid (${progress}%)` : `${progress}%`} /></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Open</p><p className="mt-1 font-semibold">{openItems.length} werkitem{openItems.length === 1 ? "" : "s"}</p><p className="mt-1 text-xs text-muted-foreground">{activePhase ? `Fase: ${activePhase.title}` : "Geen actieve fase"}</p></div></div>
|
||||||
<p className="text-xs leading-5 text-muted-foreground">Repositorywerk wordt atomair geclaimd via <code className="[overflow-wrap:anywhere]">projectbrain/auto/<work-id></code>. Een item wordt pas voltooid na succesvolle exact-head Gitea Actions-validatie en merge van de canonieke PR. Leases en callbacks blijven alleen voor compatibiliteit en herstel.</p>
|
<p className="text-xs leading-5 text-muted-foreground">Repositorywerk wordt atomair geclaimd via <code className="[overflow-wrap:anywhere]">projectbrain/auto/<work-id></code>. Een item wordt pas voltooid na succesvolle exact-head Gitea Actions-validatie en merge van de canonieke PR. Leases en callbacks blijven alleen voor compatibiliteit en herstel.</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card> : null}
|
</Card> : null}
|
||||||
@@ -112,7 +200,7 @@ const AutoMode = () => {
|
|||||||
{state?.blocker?.kind === "retryable" ? <Card className="border-amber-300 bg-amber-50/30"><CardContent className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between"><div><p className="font-semibold">Automatisch herstel</p><p className="mt-1 text-sm text-muted-foreground">{state.blocker.reason} ProjectBrain probeert dit automatisch opnieuw; jouw actie is niet nodig.</p>{state.blocker.retryAt ? <p className="mt-1 text-xs text-muted-foreground">Opnieuw uitvoerbaar vanaf {new Date(state.blocker.retryAt).toLocaleString("nl-BE")}</p> : null}</div><Button variant="outline" onClick={() => void resume()} disabled={saving}><RotateCcw className="mr-2 h-4 w-4" />Nu opnieuw proberen</Button></CardContent></Card> : null}
|
{state?.blocker?.kind === "retryable" ? <Card className="border-amber-300 bg-amber-50/30"><CardContent className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between"><div><p className="font-semibold">Automatisch herstel</p><p className="mt-1 text-sm text-muted-foreground">{state.blocker.reason} ProjectBrain probeert dit automatisch opnieuw; jouw actie is niet nodig.</p>{state.blocker.retryAt ? <p className="mt-1 text-xs text-muted-foreground">Opnieuw uitvoerbaar vanaf {new Date(state.blocker.retryAt).toLocaleString("nl-BE")}</p> : null}</div><Button variant="outline" onClick={() => void resume()} disabled={saving}><RotateCcw className="mr-2 h-4 w-4" />Nu opnieuw proberen</Button></CardContent></Card> : null}
|
||||||
{state?.blocker?.kind === "human" ? <Card className="border-destructive/40"><CardContent className="flex min-w-0 flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between"><div className="min-w-0"><p className="font-semibold text-destructive">Jouw beslissing of toegang nodig</p><p className="mt-1 text-sm text-muted-foreground">{state.blocker.reason}</p></div><div className="flex max-w-full flex-wrap gap-2"><Button variant="destructive" onClick={() => navigate(`/projects/${projectId}/attention`)}>Jouw aandacht <ArrowRight size={14} /></Button><Button variant="outline" onClick={() => void resume()} disabled={saving}>Na oplossing hervatten</Button></div></CardContent></Card> : null}
|
{state?.blocker?.kind === "human" ? <Card className="border-destructive/40"><CardContent className="flex min-w-0 flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between"><div className="min-w-0"><p className="font-semibold text-destructive">Jouw beslissing of toegang nodig</p><p className="mt-1 text-sm text-muted-foreground">{state.blocker.reason}</p></div><div className="flex max-w-full flex-wrap gap-2"><Button variant="destructive" onClick={() => navigate(`/projects/${projectId}/attention`)}>Jouw aandacht <ArrowRight size={14} /></Button><Button variant="outline" onClick={() => void resume()} disabled={saving}>Na oplossing hervatten</Button></div></CardContent></Card> : null}
|
||||||
|
|
||||||
<details className="rounded-xl border bg-card"><summary className="cursor-pointer p-4 font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">Roadmap en wachtrij</summary><div className="space-y-4 border-t p-4"><div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"><div className="min-w-0"><p className="font-semibold">Committed werk</p><p className="text-sm text-muted-foreground">{state?.roadmap ? `${openItems.length} open · ${completedItems}/${items.length} voltooid` : "Nog geen roadmap of opdrachten."}</p></div><span className="text-2xl font-bold tabular-nums">{progress}%</span></div>{phases.map((phase, index) => { const done = phase.items.filter((item) => item.status === "completed").length; return <div key={phase.id} className="min-w-0 rounded-lg border p-4"><div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"><div className="min-w-0"><p className="font-semibold">{index + 1}. {phase.title}</p><p className="mt-1 text-sm text-muted-foreground">{phase.objective}</p></div><Badge variant={phaseVariant(phase)} className="max-w-full whitespace-normal [overflow-wrap:anywhere]">{autoModePhaseLabel[phase.status]}</Badge></div><p className="mt-2 text-xs text-muted-foreground">{done}/{phase.items.length} items</p>{phase.items.length ? <div className="mt-3 grid min-w-0 gap-2 md:grid-cols-2">{phase.items.map((item) => <div key={item.id} className="min-w-0 rounded-md bg-muted/35 px-3 py-2 text-sm"><div className="flex min-w-0 items-start gap-2">{item.status === "completed" ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" /> : <span className={`mt-1 h-2 w-2 shrink-0 rounded-full ${item.status === "blocked" ? "bg-destructive" : item.status === "in_progress" ? "bg-primary" : "bg-muted-foreground/50"}`} />}<span className="min-w-0">{item.title}</span></div><p className="mt-1 pl-6 text-xs text-muted-foreground">{autoModeItemLabel[item.status]} · {item.attempts} poging(en)</p></div>)}</div> : null}</div>; })}{!items.length ? <Button variant="outline" onClick={() => navigate(`/projects/${projectId}/workspace`)}>Opdracht geven <ArrowRight className="ml-2 h-4 w-4" /></Button> : null}</div></details>
|
<details className="rounded-xl border bg-card"><summary className="cursor-pointer p-4 font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">Roadmap en wachtrij</summary><div className="space-y-4 border-t p-4"><SortableWorkQueue entries={queueEntries} saving={queueSaving} manualOrdering={queueHasManualOrder} onReorder={(itemIds) => void reorderQueue(itemIds)} onReset={() => void resetQueueOrder()} /><div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"><div className="min-w-0"><p className="font-semibold">Committed werk</p><p className="text-sm text-muted-foreground">{state?.roadmap ? `${openItems.length} open · ${completedItems}/${items.length} voltooid` : "Nog geen roadmap of opdrachten."}</p></div><span className="text-2xl font-bold tabular-nums">{progress}%</span></div>{phases.map((phase, index) => { const done = phase.items.filter((item) => item.status === "completed").length; return <div key={phase.id} className="min-w-0 rounded-lg border p-4"><div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"><div className="min-w-0"><p className="font-semibold">{index + 1}. {phase.title}</p><p className="mt-1 text-sm text-muted-foreground">{phase.objective}</p></div><Badge variant={phaseVariant(phase)} className="max-w-full whitespace-normal [overflow-wrap:anywhere]">{autoModePhaseLabel[phase.status]}</Badge></div><p className="mt-2 text-xs text-muted-foreground">{done}/{phase.items.length} items</p>{phase.items.length ? <div className="mt-3 grid min-w-0 gap-2 md:grid-cols-2">{phase.items.map((item) => <div key={item.id} className="min-w-0 rounded-md bg-muted/35 px-3 py-2 text-sm"><div className="flex min-w-0 items-start gap-2">{item.status === "completed" ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" /> : <span className={`mt-1 h-2 w-2 shrink-0 rounded-full ${item.status === "blocked" ? "bg-destructive" : item.status === "in_progress" ? "bg-primary" : "bg-muted-foreground/50"}`} />}<span className="min-w-0">{item.title}</span></div><p className="mt-1 pl-6 text-xs text-muted-foreground">{autoModeItemLabel[item.status]} · {item.attempts} poging(en)</p></div>)}</div> : null}</div>; })}{!items.length ? <Button variant="outline" onClick={() => navigate(`/projects/${projectId}/workspace`)}>Opdracht geven <ArrowRight className="ml-2 h-4 w-4" /></Button> : null}</div></details>
|
||||||
|
|
||||||
<details className="rounded-xl border bg-card"><summary className="cursor-pointer p-4 font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">Autonomiebeleid en geavanceerde instellingen</summary><div className="grid min-w-0 gap-6 border-t p-4 xl:grid-cols-2"><Card className="min-w-0"><CardHeader><CardTitle>Autonomieprofiel</CardTitle><CardDescription>Routinewerk automatiseren zonder echte risico- of productbeslissingen te verbergen.</CardDescription></CardHeader><CardContent className="space-y-4"><div className="grid min-w-0 gap-2 sm:grid-cols-2">{autonomyProfileOrder.map((profile) => { const definition = autonomyProfileDefinitions[profile]; return <Button key={profile} type="button" variant={policy.profile === profile ? "default" : "outline"} className="h-auto min-h-16 w-full min-w-0 max-w-full justify-start whitespace-normal text-left [overflow-wrap:anywhere]" onClick={() => selectProfile(profile)}><span className="min-w-0"><span className="block font-semibold">{definition.label}</span><span className="mt-1 block text-xs font-normal opacity-80">{definition.description}</span></span></Button>; })}</div><details className="rounded-lg border"><summary className="cursor-pointer p-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">Geavanceerde grenzen</summary><div className="grid min-w-0 gap-4 border-t p-3 sm:grid-cols-2"><div className="min-w-0 space-y-2"><Label htmlFor="brainstorm-iterations">Max. brainstormrondes</Label><Input id="brainstorm-iterations" type="number" min={1} max={10} value={policy.maxBrainstormIterations} onChange={(event) => updatePolicyNumber("maxBrainstormIterations", event.target.value, 1, 10)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="failure-limit">Retry-drempel</Label><Input id="failure-limit" type="number" min={1} max={10} value={policy.maxConsecutiveFailures} onChange={(event) => updatePolicyNumber("maxConsecutiveFailures", event.target.value, 1, 10)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="lease-minutes">Compatibiliteitslease (min.)</Label><Input id="lease-minutes" type="number" min={5} max={120} value={policy.leaseMinutes} onChange={(event) => updatePolicyNumber("leaseMinutes", event.target.value, 5, 120)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="run-items">Werkitems per run</Label><Input id="run-items" type="number" min={1} max={20} value={policy.maxWorkItemsPerRun} onChange={(event) => updatePolicyNumber("maxWorkItemsPerRun", event.target.value, 1, 20)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="run-minutes">Looptijd per run</Label><Input id="run-minutes" type="number" min={5} max={55} value={policy.maxRunMinutes} onChange={(event) => updatePolicyNumber("maxRunMinutes", event.target.value, 5, 55)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="recovery-minutes">Max. recoveryduur</Label><Input id="recovery-minutes" type="number" min={15} max={1440} value={policy.maxRecoveryMinutes} onChange={(event) => updatePolicyNumber("maxRecoveryMinutes", event.target.value, 15, 1440)} /></div><div className="flex min-w-0 items-center justify-between gap-4 sm:col-span-2"><div className="min-w-0"><Label htmlFor="auto-brainstorm">Convergence-brainstorm</Label><p className="text-xs text-muted-foreground">Zoek na concrete taken nog naar echte gaten.</p></div><Switch id="auto-brainstorm" checked={policy.autoBrainstorm} onCheckedChange={(checked) => setPolicy((current) => ({ ...current, profile: "custom", autoBrainstorm: checked }))} /></div></div></details><Button variant="outline" className="w-full" onClick={() => void savePolicy()} disabled={saving || !policyDirty}>Beleid opslaan</Button></CardContent></Card><Card className="min-w-0"><CardHeader><CardTitle>Roadmap handmatig importeren</CardTitle><CardDescription>Alleen voor geavanceerd beheer; gewone opdrachten gaan automatisch naar de duurzame wachtrij.</CardDescription></CardHeader><CardContent className="space-y-4"><div className="min-w-0 space-y-2"><Label htmlFor="roadmap-title">Titel</Label><Input id="roadmap-title" value={roadmapTitle} onChange={(event) => setRoadmapTitle(event.target.value)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="roadmap-markdown">Roadmap</Label><Textarea id="roadmap-markdown" value={roadmapMarkdown} onChange={(event) => { setRoadmapMarkdown(event.target.value); setConfirmRoadmapReplacement(false); }} rows={10} className="max-w-full min-w-0 font-mono text-sm" /></div>{confirmRoadmapReplacement ? <div role="alert" className="min-w-0 rounded-lg border border-amber-300 bg-amber-50/40 p-4"><p className="font-semibold">Bestaande roadmap en voortgang vervangen?</p><div className="mt-3 flex max-w-full flex-wrap gap-2"><Button variant="destructive" onClick={() => void importRoadmap()} disabled={saving}>Definitief vervangen</Button><Button variant="outline" onClick={() => setConfirmRoadmapReplaLine truncated
|
<details className="rounded-xl border bg-card"><summary className="cursor-pointer p-4 font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">Autonomiebeleid en geavanceerde instellingen</summary><div className="grid min-w-0 gap-6 border-t p-4 xl:grid-cols-2"><Card className="min-w-0"><CardHeader><CardTitle>Autonomieprofiel</CardTitle><CardDescription>Routinewerk automatiseren zonder echte risico- of productbeslissingen te verbergen.</CardDescription></CardHeader><CardContent className="space-y-4"><div className="grid min-w-0 gap-2 sm:grid-cols-2">{autonomyProfileOrder.map((profile) => { const definition = autonomyProfileDefinitions[profile]; return <Button key={profile} type="button" variant={policy.profile === profile ? "default" : "outline"} className="h-auto min-h-16 w-full min-w-0 max-w-full justify-start whitespace-normal text-left [overflow-wrap:anywhere]" onClick={() => selectProfile(profile)}><span className="min-w-0"><span className="block font-semibold">{definition.label}</span><span className="mt-1 block text-xs font-normal opacity-80">{definition.description}</span></span></Button>; })}</div><details className="rounded-lg border"><summary className="cursor-pointer p-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">Geavanceerde grenzen</summary><div className="grid min-w-0 gap-4 border-t p-3 sm:grid-cols-2"><div className="min-w-0 space-y-2"><Label htmlFor="brainstorm-iterations">Max. brainstormrondes</Label><Input id="brainstorm-iterations" type="number" min={1} max={10} value={policy.maxBrainstormIterations} onChange={(event) => updatePolicyNumber("maxBrainstormIterations", event.target.value, 1, 10)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="failure-limit">Retry-drempel</Label><Input id="failure-limit" type="number" min={1} max={10} value={policy.maxConsecutiveFailures} onChange={(event) => updatePolicyNumber("maxConsecutiveFailures", event.target.value, 1, 10)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="lease-minutes">Compatibiliteitslease (min.)</Label><Input id="lease-minutes" type="number" min={5} max={120} value={policy.leaseMinutes} onChange={(event) => updatePolicyNumber("leaseMinutes", event.target.value, 5, 120)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="run-items">Werkitems per run</Label><Input id="run-items" type="number" min={1} max={20} value={policy.maxWorkItemsPerRun} onChange={(event) => updatePolicyNumber("maxWorkItemsPerRun", event.target.value, 1, 20)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="run-minutes">Looptijd per run</Label><Input id="run-minutes" type="number" min={5} max={55} value={policy.maxRunMinutes} onChange={(event) => updatePolicyNumber("maxRunMinutes", event.target.value, 5, 55)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="recovery-minutes">Max. recoveryduur</Label><Input id="recovery-minutes" type="number" min={15} max={1440} value={policy.maxRecoveryMinutes} onChange={(event) => updatePolicyNumber("maxRecoveryMinutes", event.target.value, 15, 1440)} /></div><div className="flex min-w-0 items-center justify-between gap-4 sm:col-span-2"><div className="min-w-0"><Label htmlFor="auto-brainstorm">Convergence-brainstorm</Label><p className="text-xs text-muted-foreground">Zoek na concrete taken nog naar echte gaten.</p></div><Switch id="auto-brainstorm" checked={policy.autoBrainstorm} onCheckedChange={(checked) => setPolicy((current) => ({ ...current, profile: "custom", autoBrainstorm: checked }))} /></div></div></details><Button variant="outline" className="w-full" onClick={() => void savePolicy()} disabled={saving || !policyDirty}>Beleid opslaan</Button></CardContent></Card><Card className="min-w-0"><CardHeader><CardTitle>Roadmap handmatig importeren</CardTitle><CardDescription>Alleen voor geavanceerd beheer; gewone opdrachten gaan automatisch naar de duurzame wachtrij.</CardDescription></CardHeader><CardContent className="space-y-4"><div className="min-w-0 space-y-2"><Label htmlFor="roadmap-title">Titel</Label><Input id="roadmap-title" value={roadmapTitle} onChange={(event) => setRoadmapTitle(event.target.value)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="roadmap-markdown">Roadmap</Label><Textarea id="roadmap-markdown" value={roadmapMarkdown} onChange={(event) => { setRoadmapMarkdown(event.target.value); setConfirmRoadmapReplacement(false); }} rows={10} className="max-w-full min-w-0 font-mono text-sm" /></div>{confirmRoadmapReplacement ? <div role="alert" className="min-w-0 rounded-lg border border-amber-300 bg-amber-50/40 p-4"><p className="font-semibold">Bestaande roadmap en voortgang vervangen?</p><div className="mt-3 flex max-w-full flex-wrap gap-2"><Button variant="destructive" onClick={() => void importRoadmap()} disabled={saving}>Definitief vervangen</Button><Button variant="outline" onClick={() => setConfirmRoadmapReplaLine truncated
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Activity, AlertTriangle, ArrowRight, CheckCircle2, Clock3, Loader2, Ref
|
|||||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui";
|
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui";
|
||||||
import { realClient, type PortfolioRuntime } from "@/shared/api/realClient";
|
import { realClient, type PortfolioRuntime } from "@/shared/api/realClient";
|
||||||
import type { RuntimeJob, RuntimeRunner } from "@/shared/api/portfolioTypes";
|
import type { RuntimeJob, RuntimeRunner } from "@/shared/api/portfolioTypes";
|
||||||
import { runtimeJobGroups, runtimeJobLabel, runtimeRunnerCapacity } from "@/shared/lib/activityRuntimePresentation";
|
import { oldestRunnableRuntimeJob, runtimeJobGroups, runtimeJobLabel, runtimeRunnerCapacity } from "@/shared/lib/activityRuntimePresentation";
|
||||||
import { RuntimeJobLane } from "./RuntimeJobLane";
|
import { RuntimeJobLane } from "./RuntimeJobLane";
|
||||||
|
|
||||||
const onlineRunner = (runner: RuntimeRunner) => !runner.disabled && (runner.status === "online" || runner.status === "observed");
|
const onlineRunner = (runner: RuntimeRunner) => !runner.disabled && (runner.status === "online" || runner.status === "observed");
|
||||||
@@ -81,9 +81,27 @@ export const PortfolioExecutionPulse = () => {
|
|||||||
const groups = useMemo(() => runtimeJobGroups(runtime?.jobs ?? []), [runtime?.jobs]);
|
const groups = useMemo(() => runtimeJobGroups(runtime?.jobs ?? []), [runtime?.jobs]);
|
||||||
const capacity = useMemo(() => runtimeRunnerCapacity(runtime?.runners ?? []), [runtime?.runners]);
|
const capacity = useMemo(() => runtimeRunnerCapacity(runtime?.runners ?? []), [runtime?.runners]);
|
||||||
const runners = useMemo(() => [...(runtime?.runners ?? [])].sort((left, right) => Number(right.busy) - Number(left.busy) || Number(onlineRunner(right)) - Number(onlineRunner(left)) || left.name.localeCompare(right.name)), [runtime?.runners]);
|
const runners = useMemo(() => [...(runtime?.runners ?? [])].sort((left, right) => Number(right.busy) - Number(left.busy) || Number(onlineRunner(right)) - Number(onlineRunner(left)) || left.name.localeCompare(right.name)), [runtime?.runners]);
|
||||||
const nextJob = groups.runnable[0] ?? groups.dependencyBlocked[0];
|
const oldestRunnableJob = oldestRunnableRuntimeJob(runtime?.jobs ?? []);
|
||||||
const connectedAutoExecutors = runtime?.autonomy.executors?.filter((executor) => executor.status !== "stale")
|
const connectedAutoExecutors = runtime?.autonomy.executors?.filter((executor) => executor.status !== "stale")
|
||||||
?? (runtime?.autonomy.executor && runtime.autonomy.executor.status !== "stale" ? [runtime.autonomy.executor] : []);
|
?? (runtime?.autonomy.executor && runtime.autonomy.executor.status !== "stale" ? [runtime.autonomy.executor] : []);
|
||||||
|
const queuePreviewTitle = oldestRunnableJob
|
||||||
|
? "Eerst getoond in wachtrij"
|
||||||
|
: runtime?.counts.runnable
|
||||||
|
? "Wachtende jobs buiten dit overzicht"
|
||||||
|
: runtime?.counts.dependencyBlocked
|
||||||
|
? "Geen job wacht op een runner"
|
||||||
|
: connectedAutoExecutors.length
|
||||||
|
? "Autonome uitvoerders verbonden"
|
||||||
|
: "Geen job wacht op een runner";
|
||||||
|
const queuePreviewDetail = oldestRunnableJob
|
||||||
|
? `${oldestRunnableJob.repository ?? "Repository"} · ${oldestRunnableJob.name} · ${runtimeJobLabel(oldestRunnableJob)}`
|
||||||
|
: runtime?.counts.runnable
|
||||||
|
? `${runtime.counts.runnable} job(s) wachten op een runner, maar staan niet in dit overzicht.`
|
||||||
|
: runtime?.counts.dependencyBlocked
|
||||||
|
? `${runtime.counts.dependencyBlocked} job(s) wachten op een eerdere stap.`
|
||||||
|
: connectedAutoExecutors.length
|
||||||
|
? `${connectedAutoExecutors.length} Auto-uitvoerder(s) recent gezien · ${runtime?.autonomy.counts.pendingWorkItems ?? 0} open Auto-werkitems`
|
||||||
|
: "Er staat geen job klaar om door een runner opgepakt te worden.";
|
||||||
const executionMismatch = Boolean(runtime && runtime.counts.runnable > 0 && capacity.idle > 0);
|
const executionMismatch = Boolean(runtime && runtime.counts.runnable > 0 && capacity.idle > 0);
|
||||||
|
|
||||||
return <Card className={runtime?.availability === "unavailable" || executionMismatch ? "border-amber-300" : "border-primary/25"}>
|
return <Card className={runtime?.availability === "unavailable" || executionMismatch ? "border-amber-300" : "border-primary/25"}>
|
||||||
@@ -135,7 +153,7 @@ export const PortfolioExecutionPulse = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 rounded-lg border bg-muted/20 p-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 rounded-lg border bg-muted/20 p-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="min-w-0"><p className="font-semibold">{nextJob ? "Hierna" : connectedAutoExecutors.length ? "Autonome uitvoerders verbonden" : "Geen volgende job bevestigd"}</p><p className="mt-1 truncate text-sm text-muted-foreground">{nextJob ? `${nextJob.repository ?? "Repository"} · ${nextJob.name} · ${runtimeJobLabel(nextJob)}` : connectedAutoExecutors.length ? `${connectedAutoExecutors.length} Auto-uitvoerder(s) recent gezien · ${runtime.autonomy.counts.pendingWorkItems} open Auto-werkitems` : "ProjectBrain toont pas een volgende execution wanneer die uit de actuele runtime blijkt."}</p></div>
|
<div className="min-w-0"><p className="font-semibold">{queuePreviewTitle}</p><p className="mt-1 truncate text-sm text-muted-foreground">{queuePreviewDetail}</p><p className="mt-1 text-xs text-muted-foreground">Gitea kiest welke geschikte job wordt uitgevoerd. Dit overzicht is alleen-lezen en toont de oudste aanmaaktijd eerst.</p></div>
|
||||||
<Badge variant="secondary">{runtime.availability === "live" ? `Actueel · ${new Date(runtime.generatedAt).toLocaleTimeString("nl-BE", { hour: "2-digit", minute: "2-digit" })}` : runtime.availability}</Badge>
|
<Badge variant="secondary">{runtime.availability === "live" ? `Actueel · ${new Date(runtime.generatedAt).toLocaleTimeString("nl-BE", { hour: "2-digit", minute: "2-digit" })}` : runtime.availability}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</> : null}
|
</> : null}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
runtimeJobLabel,
|
runtimeJobLabel,
|
||||||
runtimeQueueExplanation,
|
runtimeQueueExplanation,
|
||||||
runtimeRunnerCapacity,
|
runtimeRunnerCapacity,
|
||||||
|
runtimeRunnerCapacityPresentation,
|
||||||
} from "@/shared/lib/activityRuntimePresentation";
|
} from "@/shared/lib/activityRuntimePresentation";
|
||||||
import { projectEntryPath } from "@/shared/lib/routeRegistry";
|
import { projectEntryPath } from "@/shared/lib/routeRegistry";
|
||||||
import { RuntimeJobCard, RuntimeJobLane } from "./RuntimeJobLane";
|
import { RuntimeJobCard, RuntimeJobLane } from "./RuntimeJobLane";
|
||||||
@@ -108,6 +109,10 @@ export const PortfolioRuntimeDashboard = () => {
|
|||||||
const stalledProjects = autonomy?.counts.stalledProjects ?? 0;
|
const stalledProjects = autonomy?.counts.stalledProjects ?? 0;
|
||||||
const stalledAutoProjects = (autonomy?.projects ?? []).filter((project) => project.stalled);
|
const stalledAutoProjects = (autonomy?.projects ?? []).filter((project) => project.stalled);
|
||||||
const retryingProjects = (autonomy?.projects ?? []).filter((project) => project.blockerKind === "retryable").length;
|
const retryingProjects = (autonomy?.projects ?? []).filter((project) => project.blockerKind === "retryable").length;
|
||||||
|
const claimHealth = runtime?.claimHealth;
|
||||||
|
const runnerInventoryComplete = claimHealth?.runnerInventoryComplete === true;
|
||||||
|
const capacityDisplay = runtimeRunnerCapacityPresentation(capacity, runnerInventoryComplete);
|
||||||
|
const oldestPollingStallMinutes = Math.floor((claimHealth?.oldestClaimableWaitSeconds ?? 0) / 60);
|
||||||
|
|
||||||
return <Card id="portfolio-runners" className="scroll-mt-24">
|
return <Card id="portfolio-runners" className="scroll-mt-24">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -156,12 +161,46 @@ export const PortfolioRuntimeDashboard = () => {
|
|||||||
<div className="rounded-lg border border-primary/25 bg-primary/5 p-3"><strong className="text-2xl">{runtime.counts.running}</strong><p className="text-xs text-muted-foreground">nu bezig</p></div>
|
<div className="rounded-lg border border-primary/25 bg-primary/5 p-3"><strong className="text-2xl">{runtime.counts.running}</strong><p className="text-xs text-muted-foreground">nu bezig</p></div>
|
||||||
<div className="rounded-lg border p-3"><strong className="text-2xl">{runtime.counts.runnable}</strong><p className="text-xs text-muted-foreground">wacht op runner</p></div>
|
<div className="rounded-lg border p-3"><strong className="text-2xl">{runtime.counts.runnable}</strong><p className="text-xs text-muted-foreground">wacht op runner</p></div>
|
||||||
<div className="rounded-lg border p-3"><strong className="text-2xl">{runtime.counts.dependencyBlocked}</strong><p className="text-xs text-muted-foreground">wacht op eerdere job</p></div>
|
<div className="rounded-lg border p-3"><strong className="text-2xl">{runtime.counts.dependencyBlocked}</strong><p className="text-xs text-muted-foreground">wacht op eerdere job</p></div>
|
||||||
<div className="rounded-lg border p-3"><strong className="text-2xl">{capacity.idle}/{capacity.online}</strong><p className="text-xs text-muted-foreground">online runners vrij</p></div>
|
<div className="rounded-lg border p-3"><strong className="text-2xl">{capacityDisplay.value}</strong><p className="text-xs text-muted-foreground">{capacityDisplay.label}</p></div>
|
||||||
<div className="rounded-lg border p-3"><strong className="text-lg capitalize">{runtime.worker?.status ?? "niet actief"}</strong><p className="text-xs text-muted-foreground">analyseworker</p></div>
|
<div className="rounded-lg border p-3"><strong className="text-lg capitalize">{runtime.worker?.status ?? "niet actief"}</strong><p className="text-xs text-muted-foreground">analyseworker</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`rounded-lg border p-3 text-sm ${runtime.counts.runnable ? "border-amber-300 bg-amber-50 text-amber-950" : "bg-muted/30 text-muted-foreground"}`}>
|
<div className={`rounded-lg border p-3 text-sm ${runtime.counts.runnable ? "border-amber-300 bg-amber-50 text-amber-950" : "bg-muted/30 text-muted-foreground"}`}>
|
||||||
{runtimeQueueExplanation(runtime.counts.runnable, runtime.counts.dependencyBlocked, capacity.idle)}
|
{runtimeQueueExplanation(runtime.counts.runnable, runtime.counts.dependencyBlocked, capacity.idle, runnerInventoryComplete, capacity.online)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{claimHealth && !claimHealth.coverage.complete ? <div role="status" className="rounded-lg border border-amber-300 bg-amber-50/70 p-3 text-sm text-amber-950">
|
||||||
|
<p className="font-semibold">Runner claim-health: scan onvolledig</p>
|
||||||
|
<p className="mt-1 text-xs leading-5">{claimHealth.coverage.observed} queued jobs onderzocht{claimHealth.coverage.total === null ? "; het totaal is onbekend" : ` van ${claimHealth.coverage.total}`}. Bevindingen en wachttijden hieronder gelden alleen voor de onderzochte jobs; over de overige jobs is geen conclusie mogelijk.</p>
|
||||||
|
</div> : null}
|
||||||
|
|
||||||
|
{claimHealth && !claimHealth.runnerInventoryComplete ? <div role="status" className="rounded-lg border border-amber-300 bg-amber-50/70 p-3 text-sm text-amber-950">
|
||||||
|
<p className="font-semibold">Runner claim-health: runnerinventaris niet beschikbaar</p>
|
||||||
|
<p className="mt-1 text-xs leading-5">Wachtende jobs zijn zichtbaar, maar labels en capaciteit kunnen niet betrouwbaar worden vastgesteld. Er wordt daarom geen labelmismatch of pollingstall afgeleid.</p>
|
||||||
|
</div> : null}
|
||||||
|
|
||||||
|
{claimHealth?.counts.pollingStalled ? <div className="rounded-lg border border-amber-400 bg-amber-50 p-3 text-sm text-amber-950">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div><p className="flex items-center gap-2 font-semibold"><AlertTriangle size={15} />Runner claim-health: polling gestald</p>
|
||||||
|
<p className="mt-1 text-xs leading-5">{claimHealth.coverage.complete ? "" : "Minstens "}{claimHealth.counts.pollingStalled} wachtende {claimHealth.counts.pollingStalled === 1 ? "job heeft" : "jobs hebben"} een compatibele vrije runner maar {claimHealth.counts.pollingStalled === 1 ? "wordt" : "worden"} niet geclaimd. Langste waargenomen claimbare wachttijd: {oldestPollingStallMinutes} min.</p></div>
|
||||||
|
<Badge variant="secondary">Herstel: AppOps runner fleet</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs leading-5">ProjectBrain detecteert de stall maar herstart geen nog draaiende runner rechtstreeks. Capaciteit, runnerstart/stop en herstel blijven bij de governed AppOps runner-fleetcontroller.</p>
|
||||||
|
</div> : claimHealth?.counts.labelBlocked ? <div className="rounded-lg border border-amber-300 bg-amber-50/70 p-3 text-sm text-amber-950">
|
||||||
|
<p className="font-semibold">Runner claim-health: geen labelmatch</p>
|
||||||
|
<p className="mt-1 text-xs leading-5">{claimHealth.coverage.complete ? "" : "Minstens "}{claimHealth.counts.labelBlocked} wachtende {claimHealth.counts.labelBlocked === 1 ? "job heeft" : "jobs hebben"} geen compatibele online runner. Vergelijk de vereiste joblabels met de runnerlabels hieronder.</p>
|
||||||
|
</div> : null}
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-background p-3">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">Volgorde vóór dispatch beheren</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">Gitea-jobs die hier al als <strong>wacht op runner</strong> staan zijn downstream en alleen-lezen. Versleep de ProjectBrain-wachtrij in Auto Mode om te bepalen welk klaar werk als volgende wordt aangeboden; dependencies blijven altijd leidend.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
|
{(autonomy?.projects ?? []).filter((project) => project.enabled && project.pendingWorkCount > 0).slice(0, 3).map((project) => <Button key={project.projectId} variant="outline" size="sm" asChild><Link to={`/projects/${project.projectSlug}/auto`}>{project.projectName} wachtrij</Link></Button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -196,7 +235,7 @@ export const PortfolioRuntimeDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
{!activeTotal ? <p className="flex items-center gap-2 rounded-lg bg-muted/40 p-3 text-sm text-muted-foreground"><CheckCircle2 size={15} /> Er zijn geen actieve of wachtende jobs.</p> : null}
|
{!activeTotal ? <p className="flex items-center gap-2 rounded-lg bg-muted/40 p-3 text-sm text-muted-foreground"><CheckCircle2 size={15} /> Er zijn geen actieve of wachtende jobs.</p> : null}
|
||||||
<RuntimeJobLane state="running" jobs={groups.running} defaultExpanded />
|
<RuntimeJobLane state="running" jobs={groups.running} defaultExpanded />
|
||||||
<RuntimeJobLane state="runnable" jobs={groups.runnable} />
|
<RuntimeJobLane state="runnable" jobs={groups.runnable} defaultExpanded />
|
||||||
<RuntimeJobLane state="dependency-blocked" jobs={groups.dependencyBlocked} />
|
<RuntimeJobLane state="dependency-blocked" jobs={groups.dependencyBlocked} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ChevronDown, CircleDot, Clock3, ExternalLink, PauseCircle } from "lucid
|
|||||||
import { Badge } from "@/components/ui";
|
import { Badge } from "@/components/ui";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import type { RuntimeJob, RuntimeQueueState } from "@/shared/api/portfolioTypes";
|
import type { RuntimeJob, RuntimeQueueState } from "@/shared/api/portfolioTypes";
|
||||||
import { runtimeJobDetail, runtimeJobLabel, runtimeQueueLabel } from "@/shared/lib/activityRuntimePresentation";
|
import { runtimeJobDetail, runtimeJobLabel, runtimeJobTimingLabel, runtimeQueueLabel } from "@/shared/lib/activityRuntimePresentation";
|
||||||
|
|
||||||
const failureStatuses = new Set(["failed", "failure", "denied", "cancelled"]);
|
const failureStatuses = new Set(["failed", "failure", "denied", "cancelled"]);
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ export const RuntimeJobCard = ({ job }: { job: RuntimeJob }) => {
|
|||||||
const progress = job.totalSteps > 0
|
const progress = job.totalSteps > 0
|
||||||
? Math.max(0, Math.min(100, job.completedSteps / job.totalSteps * 100))
|
? Math.max(0, Math.min(100, job.completedSteps / job.totalSteps * 100))
|
||||||
: 0;
|
: 0;
|
||||||
|
const timing = runtimeJobTimingLabel(job);
|
||||||
|
|
||||||
return <a href={job.url} target="_blank" rel="noreferrer" className="block min-w-0 rounded-lg border p-3 transition hover:border-primary/40 hover:bg-muted/30">
|
return <a href={job.url} target="_blank" rel="noreferrer" className="block min-w-0 rounded-lg border p-3 transition hover:border-primary/40 hover:bg-muted/30">
|
||||||
<div className="flex min-w-0 flex-col items-start gap-2 sm:flex-row sm:justify-between">
|
<div className="flex min-w-0 flex-col items-start gap-2 sm:flex-row sm:justify-between">
|
||||||
@@ -28,7 +29,8 @@ export const RuntimeJobCard = ({ job }: { job: RuntimeJob }) => {
|
|||||||
</div>
|
</div>
|
||||||
<Badge className="max-w-full shrink-0 self-start" variant={failed ? "destructive" : job.queueState === "running" ? "default" : "secondary"}>{label}</Badge>
|
<Badge className="max-w-full shrink-0 self-start" variant={failed ? "destructive" : job.queueState === "running" ? "default" : "secondary"}>{label}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 min-w-0 break-words text-xs text-muted-foreground [overflow-wrap:anywhere]">{runtimeJobDetail(job)}{job.totalSteps > 0 ? ` · ${job.completedSteps}/${job.totalSteps} stappen` : ""}</p>
|
<p className="mt-2 min-w-0 break-words text-xs text-muted-foreground [overflow-wrap:anywhere]">{runtimeJobDetail(job)}{job.totalSteps > 0 ? ` · ${job.completedSteps}/${job.totalSteps} stappen` : ""}{timing ? ` · ${timing}` : ""}</p>
|
||||||
|
{job.labels?.length ? <div className="mt-2 flex min-w-0 flex-wrap gap-1" aria-label="Vereiste runnerlabels">{job.labels.map((label) => <Badge key={label} variant="outline" className="max-w-full whitespace-normal text-[10px] font-normal [overflow-wrap:anywhere]">{label}</Badge>)}</div> : null}
|
||||||
{job.queueState === "running" && job.totalSteps > 0 ? <div className="mt-3 space-y-1">
|
{job.queueState === "running" && job.totalSteps > 0 ? <div className="mt-3 space-y-1">
|
||||||
<Progress value={progress} className="h-1.5" aria-label={`Voortgang ${job.name}`} />
|
<Progress value={progress} className="h-1.5" aria-label={`Voortgang ${job.name}`} />
|
||||||
</div> : null}
|
</div> : null}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type AutoModeItem = {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
status: AutoModeItemStatus;
|
status: AutoModeItemStatus;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
|
manualOrder?: number;
|
||||||
scope?: "required" | "committed" | "stretch" | "future";
|
scope?: "required" | "committed" | "stretch" | "future";
|
||||||
risk?: "low" | "normal" | "medium" | "high" | "critical";
|
risk?: "low" | "normal" | "medium" | "high" | "critical";
|
||||||
releaseBlocking?: boolean;
|
releaseBlocking?: boolean;
|
||||||
@@ -69,5 +70,9 @@ export const autonomyApi = {
|
|||||||
(await httpClient.post<AutoModeState>(`/api/projects/${encodeURIComponent(projectId)}/autonomy/roadmap`, input)).data,
|
(await httpClient.post<AutoModeState>(`/api/projects/${encodeURIComponent(projectId)}/autonomy/roadmap`, input)).data,
|
||||||
enqueueTask: async (projectId: string, input: { title?: string; instruction: string }) =>
|
enqueueTask: async (projectId: string, input: { title?: string; instruction: string }) =>
|
||||||
(await httpClient.post<{ state: AutoModeState; task: AutoModeItem; autoModeEnabled: boolean }>(`/api/projects/${encodeURIComponent(projectId)}/autonomy/tasks`, input)).data,
|
(await httpClient.post<{ state: AutoModeState; task: AutoModeItem; autoModeEnabled: boolean }>(`/api/projects/${encodeURIComponent(projectId)}/autonomy/tasks`, input)).data,
|
||||||
|
reorderQueue: async (projectId: string, itemIds: string[]) =>
|
||||||
|
(await httpClient.put<AutoModeState>(`/api/projects/${encodeURIComponent(projectId)}/autonomy/queue`, { itemIds })).data,
|
||||||
|
resetQueueOrder: async (projectId: string) =>
|
||||||
|
(await httpClient.delete<AutoModeState>(`/api/projects/${encodeURIComponent(projectId)}/autonomy/queue`)).data,
|
||||||
resume: async (projectId: string) => (await httpClient.post<AutoModeState>(`/api/projects/${encodeURIComponent(projectId)}/autonomy/resume`)).data,
|
resume: async (projectId: string) => (await httpClient.post<AutoModeState>(`/api/projects/${encodeURIComponent(projectId)}/autonomy/resume`)).data,
|
||||||
};
|
};
|
||||||
@@ -177,6 +177,7 @@ export type RuntimeJob = {
|
|||||||
queueState: RuntimeQueueState;
|
queueState: RuntimeQueueState;
|
||||||
conclusion?: string;
|
conclusion?: string;
|
||||||
runnerName?: string;
|
runnerName?: string;
|
||||||
|
labels?: string[];
|
||||||
currentStep?: string;
|
currentStep?: string;
|
||||||
completedSteps: number;
|
completedSteps: number;
|
||||||
totalSteps: number;
|
totalSteps: number;
|
||||||
@@ -186,6 +187,43 @@ export type RuntimeJob = {
|
|||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RunnerClaimDiagnosis = {
|
||||||
|
jobId: number;
|
||||||
|
jobName: string;
|
||||||
|
repository?: string;
|
||||||
|
waitSeconds: number;
|
||||||
|
claimableWaitSeconds: number;
|
||||||
|
state: "claimable" | "capacity-wait" | "label-blocked" | "polling-stalled" | "unknown";
|
||||||
|
requiredLabels: string[];
|
||||||
|
compatibleRunnerNames: string[];
|
||||||
|
compatibleIdleRunnerNames: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RunnerClaimHealth = {
|
||||||
|
status: "healthy" | "degraded" | "stalled" | "incomplete";
|
||||||
|
thresholdSeconds: number;
|
||||||
|
generatedAt: string;
|
||||||
|
oldestWaitSeconds: number;
|
||||||
|
oldestClaimableWaitSeconds: number;
|
||||||
|
coverage: {
|
||||||
|
complete: boolean;
|
||||||
|
observed: number;
|
||||||
|
total: number | null;
|
||||||
|
};
|
||||||
|
runnerInventoryComplete: boolean;
|
||||||
|
recoveryOwner: "appops-runner-fleet";
|
||||||
|
recoveryPolicy: "controller-owned";
|
||||||
|
counts: {
|
||||||
|
queued: number;
|
||||||
|
claimable: number;
|
||||||
|
capacityWait: number;
|
||||||
|
labelBlocked: number;
|
||||||
|
pollingStalled: number;
|
||||||
|
unknown: number;
|
||||||
|
};
|
||||||
|
jobs: RunnerClaimDiagnosis[];
|
||||||
|
};
|
||||||
|
|
||||||
export type AutonomyExecutorRuntime = {
|
export type AutonomyExecutorRuntime = {
|
||||||
status: "idle" | "working" | "degraded" | "stale";
|
status: "idle" | "working" | "degraded" | "stale";
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
@@ -228,6 +266,7 @@ export type PortfolioRuntime = {
|
|||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
stateVersion: string;
|
stateVersion: string;
|
||||||
warning?: string;
|
warning?: string;
|
||||||
|
claimHealth?: RunnerClaimHealth;
|
||||||
pollAfterMs: number;
|
pollAfterMs: number;
|
||||||
worker?: { status: string; activeJobId?: string; detail?: string; lastSeenAt: string };
|
worker?: { status: string; activeJobId?: string; detail?: string; lastSeenAt: string };
|
||||||
autonomy: {
|
autonomy: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { RuntimeJob, RuntimeRunner } from "@/shared/api/portfolioTypes";
|
import type { RuntimeJob, RuntimeRunner } from "@/shared/api/portfolioTypes";
|
||||||
import { getActivityOperationSections, latestCompletedRuntimeJobByRunner, prioritizeRuntimeJobs, runtimeJobGroups, runtimeJobLabel, runtimeQueueExplanation, runtimeRunnerCapacity } from "@/shared/lib/activityRuntimePresentation";
|
import { getActivityOperationSections, latestCompletedRuntimeJobByRunner, oldestRunnableRuntimeJob, prioritizeRuntimeJobs, runtimeCompatibleIdleRunnerCount, runtimeJobAgeMinutes, runtimeJobDetail, runtimeJobGroups, runtimeJobLabel, runtimeJobTimingLabel, runtimeQueueExplanation, runtimeRunnerCapacity, runtimeRunnerCapacityPresentation } from "@/shared/lib/activityRuntimePresentation";
|
||||||
|
|
||||||
const job = (queueState: RuntimeJob["queueState"], input: Partial<RuntimeJob> = {}): RuntimeJob => ({
|
const job = (queueState: RuntimeJob["queueState"], input: Partial<RuntimeJob> = {}): RuntimeJob => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -22,11 +22,83 @@ describe("activity runtime presentation", () => {
|
|||||||
expect(prioritizeRuntimeJobs([job("completed", { id: 9 }), job("dependency-blocked", { id: 8 }), job("runnable", { id: 7 }), job("running", { id: 6 })]).map((item) => item.id)).toEqual([6, 7, 8, 9]);
|
expect(prioritizeRuntimeJobs([job("completed", { id: 9 }), job("dependency-blocked", { id: 8 }), job("runnable", { id: 7 }), job("running", { id: 6 })]).map((item) => item.id)).toEqual([6, 7, 8, 9]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("orders queued work by actual waiting age rather than newest id", () => {
|
||||||
|
const ordered = prioritizeRuntimeJobs([
|
||||||
|
job("runnable", { id: 9, createdAt: "2026-09-01T12:09:00Z" }),
|
||||||
|
job("runnable", { id: 7, createdAt: "2026-09-01T12:03:00Z" }),
|
||||||
|
job("runnable", { id: 8, createdAt: "2026-09-01T12:06:00Z" }),
|
||||||
|
]);
|
||||||
|
expect(ordered.map((item) => item.id)).toEqual([7, 8, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses oldest creation time, ascending id ties, and unknown timestamps last for queued jobs", () => {
|
||||||
|
const ordered = prioritizeRuntimeJobs([
|
||||||
|
job("runnable", { id: 9, createdAt: "2026-09-01T12:03:00Z" }),
|
||||||
|
job("runnable", { id: 4, createdAt: "2026-09-01T12:03:00Z" }),
|
||||||
|
job("runnable", { id: 7, createdAt: "2026-09-01T12:06:00Z" }),
|
||||||
|
job("runnable", { id: 2, createdAt: "not-a-timestamp" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(ordered.map((item) => item.id)).toEqual([4, 9, 7, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not present dependency-blocked work as the oldest runnable job", () => {
|
||||||
|
const blocked = job("dependency-blocked", { id: 5, status: "requested", createdAt: "2026-09-01T12:00:00Z" });
|
||||||
|
expect(runtimeJobLabel(blocked)).toBe("Wacht op eerdere job");
|
||||||
|
expect(runtimeJobDetail(blocked)).toBe("Start zodra de vereiste eerdere job is afgerond");
|
||||||
|
expect(oldestRunnableRuntimeJob([
|
||||||
|
blocked,
|
||||||
|
job("dependency-blocked", { id: 4, createdAt: "2026-09-01T11:00:00Z" }),
|
||||||
|
])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects the oldest visible runnable job while ignoring dependency-blocked jobs", () => {
|
||||||
|
const selected = oldestRunnableRuntimeJob([
|
||||||
|
job("dependency-blocked", { id: 1, createdAt: "2026-09-01T11:00:00Z" }),
|
||||||
|
job("runnable", { id: 9, createdAt: "2026-09-01T12:09:00Z" }),
|
||||||
|
job("runnable", { id: 4, createdAt: "2026-09-01T12:03:00Z" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(selected?.id).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports waiting age and compatible idle runner capacity", () => {
|
||||||
|
const queued = job("runnable", { labels: ["linux-validation"], createdAt: "2026-09-01T12:00:00Z" });
|
||||||
|
const runners: RuntimeRunner[] = [
|
||||||
|
{ id: 1, name: "validation", status: "online", busy: false, disabled: false, labels: ["linux-validation"] },
|
||||||
|
{ id: 2, name: "security", status: "online", busy: false, disabled: false, labels: ["security"] },
|
||||||
|
{ id: 3, name: "busy-validation", status: "online", busy: true, disabled: false, labels: ["linux-validation"] },
|
||||||
|
];
|
||||||
|
const now = Date.parse("2026-09-01T12:07:30Z");
|
||||||
|
expect(runtimeJobAgeMinutes(queued, now)).toBe(7);
|
||||||
|
expect(runtimeJobTimingLabel(queued, now)).toBe("Wacht 7 min");
|
||||||
|
expect(runtimeCompatibleIdleRunnerCount(queued, runners)).toBe(1);
|
||||||
|
expect(runtimeCompatibleIdleRunnerCount(job("runnable", { labels: ["missing"] }), runners)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("never presents a queued job as completed", () => {
|
it("never presents a queued job as completed", () => {
|
||||||
expect(runtimeJobLabel(job("runnable"))).toBe("Wacht op runner");
|
expect(runtimeJobLabel(job("runnable"))).toBe("Wacht op runner");
|
||||||
expect(runtimeQueueExplanation(38, 0, 0)).toContain("nog geen afgeronde jobs");
|
expect(runtimeQueueExplanation(38, 0, 0)).toContain("nog geen afgeronde jobs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps capacity unknown when runner discovery is incomplete", () => {
|
||||||
|
const display = runtimeRunnerCapacityPresentation(runtimeRunnerCapacity([]), false);
|
||||||
|
expect(display).toEqual({ value: "?", label: "runnercapaciteit onbekend" });
|
||||||
|
const explanation = runtimeQueueExplanation(2, 0, 0, false);
|
||||||
|
expect(explanation).toContain("capaciteit is onbekend");
|
||||||
|
expect(explanation).not.toContain("alle online runners zijn momenteel bezig");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not describe a known-empty runner inventory as busy capacity", () => {
|
||||||
|
const explanation = runtimeQueueExplanation(2, 0, 0, true, 0);
|
||||||
|
expect(explanation).toContain("geen online runners beschikbaar");
|
||||||
|
expect(explanation).not.toContain("alle online runners zijn momenteel bezig");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes positive online capacity with no idle runners as busy", () => {
|
||||||
|
expect(runtimeQueueExplanation(2, 0, 0, true, 3)).toContain("alle online runners zijn momenteel bezig");
|
||||||
|
});
|
||||||
|
|
||||||
it("explains persistent queueing when free runners exist", () => {
|
it("explains persistent queueing when free runners exist", () => {
|
||||||
expect(runtimeQueueExplanation(2, 0, 1)).toContain("1 vrije runner");
|
expect(runtimeQueueExplanation(2, 0, 1)).toContain("1 vrije runner");
|
||||||
expect(runtimeQueueExplanation(2, 0, 2)).toContain("joblabels");
|
expect(runtimeQueueExplanation(2, 0, 2)).toContain("joblabels");
|
||||||
|
|||||||
@@ -62,9 +62,68 @@ export const getActivityOperationSections = <T extends RuntimeJob>(jobs: T[]) =>
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const activeRuntimeJobTimestamp = (job: RuntimeJob) => {
|
||||||
|
const value = job.queueState === "running" ? job.startedAt ?? job.createdAt : job.createdAt;
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : Number.MAX_SAFE_INTEGER;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runnableJobCreatedAtTimestamp = (job: RuntimeJob) => {
|
||||||
|
const timestamp = Date.parse(job.createdAt);
|
||||||
|
return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : Number.MAX_SAFE_INTEGER;
|
||||||
|
};
|
||||||
|
|
||||||
export const prioritizeRuntimeJobs = <T extends RuntimeJob>(jobs: T[]): T[] => {
|
export const prioritizeRuntimeJobs = <T extends RuntimeJob>(jobs: T[]): T[] => {
|
||||||
const rank = { running: 0, runnable: 1, "dependency-blocked": 2, completed: 3 };
|
const rank = { running: 0, runnable: 1, "dependency-blocked": 2, completed: 3 };
|
||||||
return [...jobs].sort((a, b) => rank[a.queueState] - rank[b.queueState] || b.id - a.id);
|
return [...jobs].sort((a, b) => {
|
||||||
|
const rankDifference = rank[a.queueState] - rank[b.queueState];
|
||||||
|
if (rankDifference) return rankDifference;
|
||||||
|
if (a.queueState === "runnable") {
|
||||||
|
return runnableJobCreatedAtTimestamp(a) - runnableJobCreatedAtTimestamp(b) || a.id - b.id;
|
||||||
|
}
|
||||||
|
const timeDifference = activeRuntimeJobTimestamp(a) - activeRuntimeJobTimestamp(b);
|
||||||
|
return a.queueState === "completed"
|
||||||
|
? (timeDifference ? -timeDifference : b.id - a.id)
|
||||||
|
: (timeDifference || b.id - a.id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const oldestRunnableRuntimeJob = <T extends RuntimeJob>(jobs: T[]): T | undefined =>
|
||||||
|
prioritizeRuntimeJobs(jobs.filter((job) => job.queueState === "runnable"))[0];
|
||||||
|
|
||||||
|
const normalizedRunnerLabel = (label: string) => label.trim().toLowerCase();
|
||||||
|
|
||||||
|
export const runtimeCompatibleIdleRunnerCount = (job: RuntimeJob, runners: RuntimeRunner[]) => {
|
||||||
|
const required = (job.labels ?? []).map(normalizedRunnerLabel).filter(Boolean);
|
||||||
|
return runners.filter((runner) => {
|
||||||
|
if (runner.disabled || runner.busy || (runner.status !== "online" && runner.status !== "observed")) return false;
|
||||||
|
if (!required.length) return true;
|
||||||
|
const available = new Set(runner.labels.map(normalizedRunnerLabel).filter(Boolean));
|
||||||
|
return required.every((label) => available.has(label));
|
||||||
|
}).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runtimeJobAgeMinutes = (job: RuntimeJob, now = Date.now()) => {
|
||||||
|
const timestamp = activeRuntimeJobTimestamp(job);
|
||||||
|
if (!Number.isFinite(timestamp) || timestamp === Number.MAX_SAFE_INTEGER || timestamp > now) return undefined;
|
||||||
|
return Math.max(0, Math.floor((now - timestamp) / 60_000));
|
||||||
|
};
|
||||||
|
|
||||||
|
const durationLabel = (minutes: number) => {
|
||||||
|
if (minutes < 1) return "< 1 min";
|
||||||
|
if (minutes < 60) return `${minutes} min`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainder = minutes % 60;
|
||||||
|
return remainder ? `${hours}u ${remainder}m` : `${hours}u`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runtimeJobTimingLabel = (job: RuntimeJob, now = Date.now()) => {
|
||||||
|
const minutes = runtimeJobAgeMinutes(job, now);
|
||||||
|
if (minutes === undefined) return undefined;
|
||||||
|
if (job.queueState === "runnable") return `Wacht ${durationLabel(minutes)}`;
|
||||||
|
if (job.queueState === "dependency-blocked") return `Geblokkeerd ${durationLabel(minutes)}`;
|
||||||
|
if (job.queueState === "running") return `Bezig ${durationLabel(minutes)}`;
|
||||||
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runtimeRunnerCapacity = (runners: RuntimeRunner[]) => {
|
export const runtimeRunnerCapacity = (runners: RuntimeRunner[]) => {
|
||||||
@@ -81,7 +140,26 @@ export const runtimeRunnerCapacity = (runners: RuntimeRunner[]) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runtimeQueueExplanation = (queued: number, dependencyBlocked: number, idleRunners: number) => {
|
export const runtimeRunnerCapacityPresentation = (
|
||||||
|
capacity: ReturnType<typeof runtimeRunnerCapacity>,
|
||||||
|
inventoryComplete: boolean,
|
||||||
|
) => inventoryComplete
|
||||||
|
? { value: `${capacity.idle}/${capacity.online}`, label: "online runners vrij" }
|
||||||
|
: { value: "?", label: "runnercapaciteit onbekend" };
|
||||||
|
|
||||||
|
export const runtimeQueueExplanation = (
|
||||||
|
queued: number,
|
||||||
|
dependencyBlocked: number,
|
||||||
|
idleRunners: number,
|
||||||
|
runnerInventoryComplete = true,
|
||||||
|
onlineRunners?: number,
|
||||||
|
) => {
|
||||||
|
if (queued > 0 && !runnerInventoryComplete) {
|
||||||
|
return `${queued} ${queued === 1 ? "job wacht" : "jobs wachten"} op een runner; de runnerinventaris is niet beschikbaar, dus vrije capaciteit is onbekend.`;
|
||||||
|
}
|
||||||
|
if (queued > 0 && onlineRunners === 0) {
|
||||||
|
return `${queued} ${queued === 1 ? "job wacht" : "jobs wachten"} op een runner; er zijn momenteel geen online runners beschikbaar. Dit zijn nog geen afgeronde jobs.`;
|
||||||
|
}
|
||||||
if (queued > 0 && idleRunners === 0) {
|
if (queued > 0 && idleRunners === 0) {
|
||||||
return `${queued} ${queued === 1 ? "job wacht" : "jobs wachten"} op een runner; alle online runners zijn momenteel bezig. Dit zijn nog geen afgeronde jobs.`;
|
return `${queued} ${queued === 1 ? "job wacht" : "jobs wachten"} op een runner; alle online runners zijn momenteel bezig. Dit zijn nog geen afgeronde jobs.`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user