fix: defer automatic uploads while device synchronization is pending
This commit is contained in:
+1
-1
@@ -43,7 +43,7 @@ WORKDIR /app
|
|||||||
ARG BUILD_COMMIT=development
|
ARG BUILD_COMMIT=development
|
||||||
ARG BUILD_TIME=unknown
|
ARG BUILD_TIME=unknown
|
||||||
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
|
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
|
||||||
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.22" org.opencontainers.image.revision=$BUILD_COMMIT
|
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.23" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||||
USER 10333:10333
|
USER 10333:10333
|
||||||
EXPOSE 18787
|
EXPOSE 18787
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
|||||||
+5
-7
@@ -1,9 +1,7 @@
|
|||||||
# pigway-frame-flow v0.1.22
|
# pigway-frame-flow v0.1.23
|
||||||
|
|
||||||
修复旧显示操作仍在等待同步时被后续任务替代,之后长期停留在结果未知的问题。后续常规显示已确认成功时,将较早的未确认操作收敛为 DISPLAY_NOT_CONFIRMED,保留原始错误和替代操作关联;不重发设备写入,不将旧操作误判为成功。临时覆盖、缺少上传目标、其他设备和删除操作不参与自动收敛。
|
- 自动调度和自动重试在同一设备已有图片等待同步时排队,避免 /upload 自动激活新图片而替换尚未确认的目标。等待沿用现有同步超时;保留用户手动切换与优先级行为。
|
||||||
|
- 在操作回执中保留最多 12 条设备读回状态及受限错误字段,记录实际图片、同步、在线和心跳,供定位未确认原因;不保存原始异常、URL 或凭据。采样不延长同步超时,原始上传证据保留。
|
||||||
|
- 沿用安全基础镜像、完整构建和固定镜像 ID 的预构建验收流程;不重发不确定的设备写入。
|
||||||
|
|
||||||
沿用 v0.1.21 的 Alpine 安全基础镜像和完整构建流程;本版本不沿用此前漏洞库快照的零检出结论。
|
验证:600 项测试(全量 599 项及新增超时边界用例)、Ruff、格式和 mypy 通过。历史设备物理刷新状态无法从缺失的原始采样追溯,不能将此版本描述为已修复所有网络或设备故障。
|
||||||
|
|
||||||
验证:594 项测试、Ruff、格式和 mypy 通过;真实设备结果以部署后的只读回执为准。
|
|
||||||
|
|
||||||
生产主机无法访问镜像仓库时,支持导入完整构建镜像,并在切换前核对固定镜像 ID、版本、提交及运行源码哈希;不复用旧运行镜像覆盖源码。
|
|
||||||
|
|||||||
+10
-10
@@ -5,12 +5,12 @@ b45d35d737f119a70bf40cb1f9626ff80772ed7913cb35f6e9b870d6ada01a15 .github/workfl
|
|||||||
d3ad09bdc5dc60c41a774e1cad43f3e3f8ae5c851303ae6ec2aac030392a2939 .github/workflows/verify.yml
|
d3ad09bdc5dc60c41a774e1cad43f3e3f8ae5c851303ae6ec2aac030392a2939 .github/workflows/verify.yml
|
||||||
3931cc314019760a582e0914e2d9c0f3b862a004d0a30d374900edcad5f599e5 .gitignore
|
3931cc314019760a582e0914e2d9c0f3b862a004d0a30d374900edcad5f599e5 .gitignore
|
||||||
4b4ed4926a350cbeb71436541b4566e4ba7d2494ac95236b88827a9f5dd171c2 CONTRIBUTING.md
|
4b4ed4926a350cbeb71436541b4566e4ba7d2494ac95236b88827a9f5dd171c2 CONTRIBUTING.md
|
||||||
e210623dda97c2bcbd57f37d264c912ac11a20ab3bbd6fbf616439edd3a3491a Dockerfile
|
c6fc984a1ca46cbd3e06de4591e41d21e4eb592416385d4000ade8fd570aad0c Dockerfile
|
||||||
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
|
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
|
||||||
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
|
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
|
||||||
26ad0d9063c0ee843f0f532604b8fd66e8b39fd72d129595ed078677c0d6d13c README.md
|
26ad0d9063c0ee843f0f532604b8fd66e8b39fd72d129595ed078677c0d6d13c README.md
|
||||||
ee00cb267b38485f1e574d610f0a57b51d4ae575731a18e57d06a7c43dc0a8b0 README.zh-CN.md
|
ee00cb267b38485f1e574d610f0a57b51d4ae575731a18e57d06a7c43dc0a8b0 README.zh-CN.md
|
||||||
360222ab2f70226dd5edbca358e540b02cfc0a3d5398099ddda6dd4b975c9706 RELEASE_NOTES.md
|
668d9245385b20a1d7052b96c982a4d4654e40b0e2e978522d4bef513b85f668 RELEASE_NOTES.md
|
||||||
6a0c85c8d52a2d4a3b779dce306a93c4efc4dfe3ab3b23e4a6a3bb7c55079910 SECURITY.md
|
6a0c85c8d52a2d4a3b779dce306a93c4efc4dfe3ab3b23e4a6a3bb7c55079910 SECURITY.md
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep
|
||||||
@@ -33,23 +33,23 @@ edf44436fd044561539c48480c481e66c1a38164f51ba5226cde04c756f7bacb docs/PHOTO_SOU
|
|||||||
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
|
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
|
||||||
fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md
|
fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md
|
||||||
ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md
|
ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md
|
||||||
99ea92e28bb57d90a754ba894a86603d7b0526f8b4785fcc52ac03747147e41e docs/openapi.json
|
1794e3b09fa4eb9fb59cd18f729f49d6387885fc97a0b4f227e825cc49603554 docs/openapi.json
|
||||||
54bf7061e0626900ce35e19e81efc5c32c8fd6a0438bdad68f494e1ce81616d4 docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md
|
54bf7061e0626900ce35e19e81efc5c32c8fd6a0438bdad68f494e1ce81616d4 docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md
|
||||||
a07cbe71571c6e2d5a1b94897bd63321c74675c05d15438c4964eef39db0fd76 docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md
|
a07cbe71571c6e2d5a1b94897bd63321c74675c05d15438c4964eef39db0fd76 docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md
|
||||||
ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/KOKONNA_LOCAL_CONTROL.md
|
ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/KOKONNA_LOCAL_CONTROL.md
|
||||||
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
|
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py
|
||||||
e2e2e8b10b74d51752b26516f74b9ab029585beedef50d2ac24e62a2672b3aed pigway_frame_flow/api/app.py
|
f28c2277b30d583fb7702e425ca99fe229c7e7bdc45b6142fc1bfe2e989440a6 pigway_frame_flow/api/app.py
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
|
||||||
fbadf6beaf25b9670dcbf125ec9d192188cf9e076ee4d4a0b5decdaf3834417c pigway_frame_flow/core/accounts.py
|
fbadf6beaf25b9670dcbf125ec9d192188cf9e076ee4d4a0b5decdaf3834417c pigway_frame_flow/core/accounts.py
|
||||||
818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py
|
818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py
|
||||||
8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py
|
8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py
|
||||||
81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py
|
81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py
|
||||||
4f336b86bea7747ad8d0706f2954cf9a33674139b46c9a9d8f898ee1f036462b pigway_frame_flow/display/recovery.py
|
5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py
|
||||||
e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py
|
e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py
|
||||||
7e511f06442b333af777aa1ed034afaaa2deb0c1254da2cde86d28716b9162a9 pigway_frame_flow/display/service.py
|
faff3c264ef1ffe496d2989da0b7ee0add60bda1b7ff92cf47e4fc7690b95242 pigway_frame_flow/display/service.py
|
||||||
0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py
|
0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py
|
||||||
9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py
|
9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py
|
||||||
@@ -82,7 +82,7 @@ a518965aa4db9abeb60865c315229c3053c8e9edd26d80f24dc0af0528bad9e6 pigway_frame_f
|
|||||||
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 pigway_frame_flow/integrations/kokonna_official/widget_catalog.json
|
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 pigway_frame_flow/integrations/kokonna_official/widget_catalog.json
|
||||||
aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py
|
aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py
|
||||||
1fe53f4bfb407b8db774fe730131ecf1213e16d752a3506c4c56b46365ea14e8 pigway_frame_flow/main.py
|
1fe53f4bfb407b8db774fe730131ecf1213e16d752a3506c4c56b46365ea14e8 pigway_frame_flow/main.py
|
||||||
4befa445620c3f6f2e9ad535946a84d34b063d146ee170500cd0532cf9b0779e pigway_frame_flow/models.py
|
c3fbe6af9d22989f1dc2fa980f64cf51672d7d45c28c5c8e88ba121a9709b578 pigway_frame_flow/models.py
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/providers/__init__.py
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/providers/__init__.py
|
||||||
b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py
|
b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py
|
||||||
58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py
|
58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py
|
||||||
@@ -120,12 +120,12 @@ c43faa235848bec6370d7a3d8a7b3475cbd8eb4a6e494518dfaf160ca8f77bfa pigway_frame_f
|
|||||||
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
|
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
|
||||||
fe19e508ad60fab0ab4ca61f5d966f8340143e89f1572de00e78b3f1fed8384b pigway_frame_flow/web/features.js
|
fe19e508ad60fab0ab4ca61f5d966f8340143e89f1572de00e78b3f1fed8384b pigway_frame_flow/web/features.js
|
||||||
3c4e3be6321d40547d45724fc3c54af090ebbbc2a41dec717966596577c72c5d pigway_frame_flow/web/i18n.js
|
3c4e3be6321d40547d45724fc3c54af090ebbbc2a41dec717966596577c72c5d pigway_frame_flow/web/i18n.js
|
||||||
6310ad63f0c245e2fc640380164172c6513578300eaba744e13132210c467dd6 pigway_frame_flow/web/index.html
|
2086605c67bb818b5cabae20ea4d9ccecb17e350c59cdb0d0f2ba1b70f7c8601 pigway_frame_flow/web/index.html
|
||||||
0609e796b5ca8346405b1bb6e65ac9f88ee163f7a93024d96d54d4e6ab96ba41 pigway_frame_flow/web/style.css
|
0609e796b5ca8346405b1bb6e65ac9f88ee163f7a93024d96d54d4e6ab96ba41 pigway_frame_flow/web/style.css
|
||||||
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE
|
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE
|
||||||
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
|
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
|
||||||
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
|
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
|
||||||
92bc0abc4d0a8747bb33d620330d83d4981e20ef084f210f98f084e905d553e7 pyproject.toml
|
97aaf8a332dcf472b8933b0a39aa329865133171e828892d932d29694e011ea5 pyproject.toml
|
||||||
d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock
|
d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock
|
||||||
ae264ad19391bb2515def85963f39458cfa75a8611ff54e5329106c35bd8cc6e scripts/build-verify.sh
|
ae264ad19391bb2515def85963f39458cfa75a8611ff54e5329106c35bd8cc6e scripts/build-verify.sh
|
||||||
43e8345ac5f29c2a5d1fa751c0e7c963b00b5391817393541e72c52919f0f2b4 scripts/check-compose-remote.py
|
43e8345ac5f29c2a5d1fa751c0e7c963b00b5391817393541e72c52919f0f2b4 scripts/check-compose-remote.py
|
||||||
@@ -207,7 +207,7 @@ f14bad45534041ff0b7dead21d2952ec9d6c60e9f718ab5393bdf5a47b2a6d3e tests/test_pho
|
|||||||
ab773ca6868ec007786e7142d5425663a5c8da35b9aa90837ac8e46b51e51507 tests/test_production_bootstrap.py
|
ab773ca6868ec007786e7142d5425663a5c8da35b9aa90837ac8e46b51e51507 tests/test_production_bootstrap.py
|
||||||
fee152c5f6eaeda712d38cf88a79213f5e885ba323eb15dfa5a12cf4c88028c3 tests/test_production_deploy.py
|
fee152c5f6eaeda712d38cf88a79213f5e885ba323eb15dfa5a12cf4c88028c3 tests/test_production_deploy.py
|
||||||
f0601f5ed89e8a14a4584a9683611aa7510a964f50a642348b8ab6c62df72278 tests/test_publication_security.py
|
f0601f5ed89e8a14a4584a9683611aa7510a964f50a642348b8ab6c62df72278 tests/test_publication_security.py
|
||||||
484e005d5211509aa9209ad487b4623292daa878ddec773fdbc3ed8b5b744bef tests/test_recovery.py
|
1f6ca49673eae895dacfb5dd74cecf72b2136c218078ab99e24ae766a1340ed8 tests/test_recovery.py
|
||||||
072e8ca2fa68ca73fb078b107ced96ea683652410b286017db883c97608d78f6 tests/test_retention.py
|
072e8ca2fa68ca73fb078b107ced96ea683652410b286017db883c97608d78f6 tests/test_retention.py
|
||||||
ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_scheduler.py
|
ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_scheduler.py
|
||||||
0cccc08adf35a06dbf704eb5c35966bc1c3de0ce39d3014efd7ca751253ce79f tests/test_server_release.py
|
0cccc08adf35a06dbf704eb5c35966bc1c3de0ce39d3014efd7ca751253ce79f tests/test_server_release.py
|
||||||
|
|||||||
+3
-3
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "PIGWay Frame Flow API",
|
"title": "PIGWay Frame Flow API",
|
||||||
"version": "0.1.22"
|
"version": "0.1.23"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/devices/{device_id}/widgets/selection": {
|
"/api/v1/devices/{device_id}/widgets/selection": {
|
||||||
@@ -15227,7 +15227,7 @@
|
|||||||
"version": {
|
"version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Version",
|
"title": "Version",
|
||||||
"default": "0.1.22"
|
"default": "0.1.23"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -17490,7 +17490,7 @@
|
|||||||
"version": {
|
"version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Version",
|
"title": "Version",
|
||||||
"default": "0.1.22"
|
"default": "0.1.23"
|
||||||
},
|
},
|
||||||
"api_version": {
|
"api_version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ def create_app(settings=None, start_worker=True):
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="PIGWay Frame Flow API",
|
title="PIGWay Frame Flow API",
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
version="0.1.22",
|
version="0.1.23",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
responses=error_responses,
|
responses=error_responses,
|
||||||
)
|
)
|
||||||
@@ -1040,7 +1040,7 @@ def create_app(settings=None, start_worker=True):
|
|||||||
supports_upload=True,
|
supports_upload=True,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
version="0.1.22",
|
version="0.1.23",
|
||||||
mock=settings.mock,
|
mock=settings.mock,
|
||||||
write_enabled=settings.write_enabled,
|
write_enabled=settings.write_enabled,
|
||||||
api_key_configured=bool(settings.api_key)
|
api_key_configured=bool(settings.api_key)
|
||||||
|
|||||||
@@ -74,6 +74,51 @@ class RecoveryMonitor:
|
|||||||
row["requires_manual_review"] = row["state"] == "manual_review"
|
row["requires_manual_review"] = row["state"] == "manual_review"
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
def observe_device(self, op, *, device=None, error=None):
|
||||||
|
row = self.db.one("SELECT result FROM operations WHERE id=?", (op["id"],))
|
||||||
|
result = json.loads(row["result"] or "{}")
|
||||||
|
observation = {"checked_at": now_iso()}
|
||||||
|
if device is not None:
|
||||||
|
observation.update(
|
||||||
|
image_id=device.current_content.id,
|
||||||
|
synced=device.current_content.synced,
|
||||||
|
online=device.status == "online",
|
||||||
|
heartbeat=device.last_seen_at,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
observation["read_error"] = {
|
||||||
|
"code": error.code,
|
||||||
|
**{
|
||||||
|
k: v
|
||||||
|
for k, v in error.details.items()
|
||||||
|
if k
|
||||||
|
in ("transport_error", "http_status", "attempts", "retry_exhausted")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
samples = result.get("sync_observations", [])
|
||||||
|
if samples:
|
||||||
|
old = samples[-1]
|
||||||
|
changed = {k: v for k, v in old.items() if k != "checked_at"} != {
|
||||||
|
k: v for k, v in observation.items() if k != "checked_at"
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
not changed
|
||||||
|
and (
|
||||||
|
datetime.now(UTC) - datetime.fromisoformat(old["checked_at"])
|
||||||
|
).total_seconds()
|
||||||
|
< 60
|
||||||
|
):
|
||||||
|
return observation
|
||||||
|
samples.append(observation)
|
||||||
|
result["sync_observations"] = (
|
||||||
|
[samples[0], *samples[-11:]] if len(samples) > 12 else samples
|
||||||
|
)
|
||||||
|
# Evidence must not reset updated_at: that is the sync timeout's clock.
|
||||||
|
self.db.execute(
|
||||||
|
"UPDATE operations SET result=? WHERE id=?", (encode(result), op["id"])
|
||||||
|
)
|
||||||
|
return observation
|
||||||
|
|
||||||
def close_superseded_displays(self):
|
def close_superseded_displays(self):
|
||||||
for op in self.db.all(
|
for op in self.db.all(
|
||||||
"SELECT * FROM operations WHERE action='display' AND write_started=1 "
|
"SELECT * FROM operations WHERE action='display' AND write_started=1 "
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ class DisplayService:
|
|||||||
if result:
|
if result:
|
||||||
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
|
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
|
||||||
previous = json.loads(row["result"]) if row and row["result"] else {}
|
previous = json.loads(row["result"]) if row and row["result"] else {}
|
||||||
for key in ("photo_attempts", "upload_intent"):
|
for key in ("photo_attempts", "upload_intent", "sync_observations"):
|
||||||
if key in previous:
|
if key in previous:
|
||||||
result = {**result, key: previous[key]}
|
result = {**result, key: previous[key]}
|
||||||
self.db.execute(
|
self.db.execute(
|
||||||
@@ -519,6 +519,23 @@ class DisplayService:
|
|||||||
continue
|
continue
|
||||||
payload = json.loads(op["payload"])
|
payload = json.loads(op["payload"])
|
||||||
if op["action"] == "display":
|
if op["action"] == "display":
|
||||||
|
if op["client_id"] in (
|
||||||
|
"scheduler",
|
||||||
|
"scheduler-retry",
|
||||||
|
) and self.db.one(
|
||||||
|
"SELECT id FROM operations WHERE device_id=? AND action='display' "
|
||||||
|
"AND status='SYNCING' AND write_started=1 AND updated_at>? LIMIT 1",
|
||||||
|
(
|
||||||
|
op["device_id"],
|
||||||
|
(
|
||||||
|
datetime.now(UTC)
|
||||||
|
- timedelta(seconds=self.settings.sync_timeout)
|
||||||
|
).isoformat(),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
# Upload activates a new image upstream. Give the prior
|
||||||
|
# image its existing confirmation window before replacing it.
|
||||||
|
continue
|
||||||
priority = payload["options"]["priority"]
|
priority = payload["options"]["priority"]
|
||||||
top = self.top(op["device_id"])
|
top = self.top(op["device_id"])
|
||||||
if top and priority < top["priority"]:
|
if top and priority < top["priority"]:
|
||||||
@@ -823,6 +840,7 @@ class DisplayService:
|
|||||||
try:
|
try:
|
||||||
driver = self.registry.get(op["device_id"])
|
driver = self.registry.get(op["device_id"])
|
||||||
device = driver.device(refresh=True)
|
device = driver.device(refresh=True)
|
||||||
|
self.recovery.observe_device(op, device=device)
|
||||||
if intent:
|
if intent:
|
||||||
expected = self.upload_reconciliation.check(op, driver, device)
|
expected = self.upload_reconciliation.check(op, driver, device)
|
||||||
if (
|
if (
|
||||||
@@ -850,7 +868,8 @@ class DisplayService:
|
|||||||
if device.current_content.id == expected
|
if device.current_content.id == expected
|
||||||
else "target_mismatch",
|
else "target_mismatch",
|
||||||
)
|
)
|
||||||
except FlowError:
|
except FlowError as exc:
|
||||||
|
self.recovery.observe_device(op, error=exc)
|
||||||
self.recovery.observe(op, "read_unavailable")
|
self.recovery.observe(op, "read_unavailable")
|
||||||
except Exception:
|
except Exception:
|
||||||
# Bad vendor metadata for one operation must not stop the worker.
|
# Bad vendor metadata for one operation must not stop the worker.
|
||||||
@@ -858,6 +877,7 @@ class DisplayService:
|
|||||||
for op in self.db.all("SELECT * FROM operations WHERE status='SYNCING'"):
|
for op in self.db.all("SELECT * FROM operations WHERE status='SYNCING'"):
|
||||||
try:
|
try:
|
||||||
device = self.registry.get(op["device_id"]).device()
|
device = self.registry.get(op["device_id"]).device()
|
||||||
|
observation = self.recovery.observe_device(op, device=device)
|
||||||
target = json.loads(op["result"])["image_id"]
|
target = json.loads(op["result"])["image_id"]
|
||||||
if (
|
if (
|
||||||
device.current_content.id == target
|
device.current_content.id == target
|
||||||
@@ -878,11 +898,11 @@ class DisplayService:
|
|||||||
"code": E.WRITE_RESULT_UNKNOWN,
|
"code": E.WRITE_RESULT_UNKNOWN,
|
||||||
"message": "Frame refresh was not confirmed",
|
"message": "Frame refresh was not confirmed",
|
||||||
"request_id": op["request_id"],
|
"request_id": op["request_id"],
|
||||||
"details": {},
|
"details": {"last_device_observation": observation},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except FlowError:
|
except FlowError as exc:
|
||||||
pass
|
self.recovery.observe_device(op, error=exc)
|
||||||
|
|
||||||
self.recovery.close_superseded_displays()
|
self.recovery.close_superseded_displays()
|
||||||
|
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ class SettingsView(BaseModel):
|
|||||||
|
|
||||||
class Health(BaseModel):
|
class Health(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
version: str = "0.1.22"
|
version: str = "0.1.23"
|
||||||
|
|
||||||
|
|
||||||
class Readiness(BaseModel):
|
class Readiness(BaseModel):
|
||||||
@@ -397,7 +397,7 @@ class RestoreRequest(StrictModel):
|
|||||||
|
|
||||||
class Version(BaseModel):
|
class Version(BaseModel):
|
||||||
product: str = "PIGWay Frame Flow"
|
product: str = "PIGWay Frame Flow"
|
||||||
version: str = "0.1.22"
|
version: str = "0.1.23"
|
||||||
api_version: str = "v1"
|
api_version: str = "v1"
|
||||||
commit: str = "development"
|
commit: str = "development"
|
||||||
build_time: str = "unknown"
|
build_time: str = "unknown"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
|
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
|
||||||
<body><aside class="sidebar"><a class="brand" href="/"><span>PIGWay Frame Flow<small>Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.22</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.22"></script><script src="/ui/features.js?v=0.1.22"></script><script src="/ui/extensions.js?v=0.1.22"></script><script src="/ui/app.js?v=0.1.22"></script></body></html>
|
<body><aside class="sidebar"><a class="brand" href="/"><span>PIGWay Frame Flow<small>Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.23</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.23"></script><script src="/ui/features.js?v=0.1.23"></script><script src="/ui/extensions.js?v=0.1.23"></script><script src="/ui/app.js?v=0.1.23"></script></body></html>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "pigway-frame-flow"
|
name = "pigway-frame-flow"
|
||||||
version = "0.1.22"
|
version = "0.1.23"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
requires-python = ">=3.14,<3.15"
|
requires-python = ">=3.14,<3.15"
|
||||||
|
|||||||
+107
-1
@@ -61,7 +61,9 @@ def test_persistent_block_escalates_once_survives_restart_and_preserves_evidence
|
|||||||
== 1
|
== 1
|
||||||
)
|
)
|
||||||
assert service.operation(operation)["recovery"]["checks"] == 3
|
assert service.operation(operation)["recovery"]["checks"] == 3
|
||||||
assert service.operation(operation)["result"] == original["result"]
|
current_result = service.operation(operation)["result"]
|
||||||
|
assert current_result.pop("sync_observations")
|
||||||
|
assert current_result == original["result"]
|
||||||
service.reconcile(operation, acknowledge=True)
|
service.reconcile(operation, acknowledge=True)
|
||||||
assert service.operation(operation)["recovery"]["state"] == "resolved"
|
assert service.operation(operation)["recovery"]["state"] == "resolved"
|
||||||
assert not service.operation(operation)["recovery"]["requires_manual_review"]
|
assert not service.operation(operation)["recovery"]["requires_manual_review"]
|
||||||
@@ -164,3 +166,107 @@ def test_confirmed_temporary_override_does_not_close_earlier_display(service):
|
|||||||
service.sync()
|
service.sync()
|
||||||
assert service.operation(second)["status"] == "COMPLETED"
|
assert service.operation(second)["status"] == "COMPLETED"
|
||||||
assert service.operation(first)["status"] == "SYNCING"
|
assert service.operation(first)["status"] == "SYNCING"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("client_id", ["scheduler", "scheduler-retry"])
|
||||||
|
def test_automatic_upload_waits_for_prior_sync_confirmation(service, client_id):
|
||||||
|
first = submit(service)
|
||||||
|
service.process_one()
|
||||||
|
payload = service.db.one("SELECT payload FROM operations WHERE id=?", (first,))[
|
||||||
|
"payload"
|
||||||
|
]
|
||||||
|
import json
|
||||||
|
|
||||||
|
second = service.submit(
|
||||||
|
"display", json.loads(payload), "living-room", client_id, "fixture"
|
||||||
|
)
|
||||||
|
uploads = service.registry.get("living-room").client.uploads
|
||||||
|
service.process_one()
|
||||||
|
assert service.operation(second)["status"] == "QUEUED"
|
||||||
|
assert service.registry.get("living-room").client.uploads == uploads
|
||||||
|
service.sync()
|
||||||
|
assert service.operation(first)["status"] == "COMPLETED"
|
||||||
|
service.process_one()
|
||||||
|
service.sync()
|
||||||
|
assert service.operation(second)["status"] == "COMPLETED"
|
||||||
|
assert service.registry.get("living-room").client.uploads == uploads + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_evidence_is_bounded_and_does_not_extend_sync_timeout(service):
|
||||||
|
first = submit(service)
|
||||||
|
service.process_one()
|
||||||
|
op = service.db.one("SELECT * FROM operations WHERE id=?", (first,))
|
||||||
|
before = service.operation(first)["updated_at"]
|
||||||
|
device = service.registry.get("living-room").device()
|
||||||
|
for i in range(20):
|
||||||
|
sample = device.model_copy(
|
||||||
|
update={
|
||||||
|
"current_content": device.current_content.model_copy(
|
||||||
|
update={"id": 1000 + i}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
service.recovery.observe_device(op, device=sample)
|
||||||
|
result = service.operation(first)
|
||||||
|
samples = result["result"]["sync_observations"]
|
||||||
|
assert len(samples) == 12
|
||||||
|
assert samples[0]["image_id"] == 1000 and samples[-1]["image_id"] == 1019
|
||||||
|
assert result["updated_at"] == before
|
||||||
|
service.update(first, "UNKNOWN", {"image_id": 7})
|
||||||
|
assert service.operation(first)["result"]["sync_observations"] == samples
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_read_failure_evidence_never_copies_raw_error_details(service):
|
||||||
|
first = submit(service)
|
||||||
|
service.process_one()
|
||||||
|
op = service.db.one("SELECT * FROM operations WHERE id=?", (first,))
|
||||||
|
service.recovery.observe_device(
|
||||||
|
op,
|
||||||
|
error=FlowError(
|
||||||
|
ErrorCode.CLOUD_UNAVAILABLE,
|
||||||
|
"unavailable",
|
||||||
|
details={
|
||||||
|
"transport_error": "read_timeout",
|
||||||
|
"url": "private-url-must-not-be-stored",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sample = service.operation(first)["result"]["sync_observations"][-1]
|
||||||
|
assert sample["read_error"]["transport_error"] == "read_timeout"
|
||||||
|
assert "private-url" not in str(sample)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_timeout_includes_observed_device_state(service):
|
||||||
|
first = submit(service)
|
||||||
|
service.process_one()
|
||||||
|
service.registry.get("living-room").client.synced = False
|
||||||
|
service.registry.get("living-room").invalidate()
|
||||||
|
service.settings.sync_timeout = 0
|
||||||
|
service.sync()
|
||||||
|
op = service.operation(first)
|
||||||
|
assert op["status"] == "UNKNOWN"
|
||||||
|
assert op["error"]["details"]["last_device_observation"]["synced"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_automatic_wait_does_not_extend_past_sync_timeout(service):
|
||||||
|
import json
|
||||||
|
|
||||||
|
first = submit(service)
|
||||||
|
service.process_one()
|
||||||
|
payload = service.db.one("SELECT payload FROM operations WHERE id=?", (first,))[
|
||||||
|
"payload"
|
||||||
|
]
|
||||||
|
service.db.execute(
|
||||||
|
"UPDATE operations SET updated_at=? WHERE id=?",
|
||||||
|
(
|
||||||
|
(
|
||||||
|
datetime.now(UTC) - timedelta(seconds=service.settings.sync_timeout + 1)
|
||||||
|
).isoformat(),
|
||||||
|
first,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
second = service.submit(
|
||||||
|
"display", json.loads(payload), "living-room", "scheduler", "fixture"
|
||||||
|
)
|
||||||
|
service.process_one()
|
||||||
|
assert service.operation(second)["status"] == "SYNCING"
|
||||||
|
|||||||
Reference in New Issue
Block a user