diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 35a1a3ac..c0cc16b1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,10 +15,10 @@ jobs:
name: Lint (ruff)
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: "3.11"
cache: pip
@@ -37,10 +37,10 @@ jobs:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: pip
@@ -58,10 +58,10 @@ jobs:
run:
working-directory: frontend-svelte
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: "20"
cache: npm
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index c35861e4..8a86d2ef 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -32,7 +32,7 @@ jobs:
build-mode: none
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
diff --git a/frontend-svelte/package-lock.json b/frontend-svelte/package-lock.json
index 74ffaa81..828749b1 100644
--- a/frontend-svelte/package-lock.json
+++ b/frontend-svelte/package-lock.json
@@ -9,47 +9,44 @@
"version": "0.0.0",
"dependencies": {
"svelte-i18n": "^4.0.1",
- "svelte-spa-router": "^5.0.0"
+ "svelte-spa-router": "^5.0.1"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.0.0",
- "svelte": "^5.53.12",
- "vite": "^8.0.1"
+ "svelte": "^5.55.4",
+ "vite": "^8.0.8"
}
},
"node_modules/@emnapi/core": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
- "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
+ "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
- "@emnapi/wasi-threads": "1.2.0",
+ "@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
- "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
+ "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
- "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -151,9 +148,9 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
- "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==",
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -170,9 +167,9 @@
}
},
"node_modules/@oxc-project/types": {
- "version": "0.122.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
- "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
+ "version": "0.124.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
+ "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -180,9 +177,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
- "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
"cpu": [
"arm64"
],
@@ -197,9 +194,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
- "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
"cpu": [
"arm64"
],
@@ -214,9 +211,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
- "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
"cpu": [
"x64"
],
@@ -231,9 +228,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
- "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
"cpu": [
"x64"
],
@@ -248,9 +245,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
- "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
+ "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
"cpu": [
"arm"
],
@@ -265,16 +262,13 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
- "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
"cpu": [
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -285,16 +279,13 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
- "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
+ "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
"cpu": [
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -305,16 +296,13 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
- "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
"cpu": [
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -325,16 +313,13 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
- "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
"cpu": [
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -345,16 +330,13 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
- "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
"cpu": [
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -365,16 +347,13 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
- "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
+ "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
"cpu": [
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -385,9 +364,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
- "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
"cpu": [
"arm64"
],
@@ -402,9 +381,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
- "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
+ "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
"cpu": [
"wasm32"
],
@@ -412,16 +391,18 @@
"license": "MIT",
"optional": true,
"dependencies": {
- "@napi-rs/wasm-runtime": "^1.1.1"
+ "@emnapi/core": "1.9.2",
+ "@emnapi/runtime": "1.9.2",
+ "@napi-rs/wasm-runtime": "^1.1.3"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
- "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
+ "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
"cpu": [
"arm64"
],
@@ -436,9 +417,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
- "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
+ "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
"cpu": [
"x64"
],
@@ -453,9 +434,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
- "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
+ "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
"dev": true,
"license": "MIT"
},
@@ -946,9 +927,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -970,9 +948,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -994,9 +969,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1018,9 +990,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1223,14 +1192,14 @@
}
},
"node_modules/rolldown": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
- "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
+ "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@oxc-project/types": "=0.122.0",
- "@rolldown/pluginutils": "1.0.0-rc.12"
+ "@oxc-project/types": "=0.124.0",
+ "@rolldown/pluginutils": "1.0.0-rc.15"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -1239,21 +1208,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.0.0-rc.12",
- "@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
- "@rolldown/binding-darwin-x64": "1.0.0-rc.12",
- "@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
- "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
- "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
- "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
- "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
- "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
- "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
- "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
- "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
- "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
- "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
- "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
+ "@rolldown/binding-android-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.15",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
}
},
"node_modules/sade": {
@@ -1279,9 +1248,9 @@
}
},
"node_modules/svelte": {
- "version": "5.55.0",
- "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz",
- "integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==",
+ "version": "5.55.4",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz",
+ "integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@@ -1295,7 +1264,7 @@
"clsx": "^2.1.1",
"devalue": "^5.6.4",
"esm-env": "^1.2.1",
- "esrap": "^2.2.2",
+ "esrap": "^2.2.4",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
@@ -1736,9 +1705,9 @@
}
},
"node_modules/svelte-spa-router": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/svelte-spa-router/-/svelte-spa-router-5.0.0.tgz",
- "integrity": "sha512-4F2irDGf2CkPxAz/tr/GwnAErZuG9l8J3ClHo2QfKPh0Ul4j9K9R3q91RaoekXZzJW90NHMY+dKLM9eF6vdcyA==",
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/svelte-spa-router/-/svelte-spa-router-5.0.1.tgz",
+ "integrity": "sha512-AU/4rosuCnkMLqVYV+HRYP/jLVBAhGzeIUaPGaSmFj7wLfyfZeW18iSiYlMEj2y4Uh3+8GYUt3tvObJiBmgzyA==",
"license": "MIT",
"dependencies": {
"regexparam": "2.0.2"
@@ -1800,16 +1769,16 @@
"license": "ISC"
},
"node_modules/vite": {
- "version": "8.0.3",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
- "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
+ "version": "8.0.8",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
+ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.8",
- "rolldown": "1.0.0-rc.12",
+ "rolldown": "1.0.0-rc.15",
"tinyglobby": "^0.2.15"
},
"bin": {
@@ -1827,7 +1796,7 @@
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
- "esbuild": "^0.27.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
diff --git a/frontend-svelte/package.json b/frontend-svelte/package.json
index 87e7f153..729bd439 100644
--- a/frontend-svelte/package.json
+++ b/frontend-svelte/package.json
@@ -11,11 +11,11 @@
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.0.0",
- "svelte": "^5.53.12",
- "vite": "^8.0.1"
+ "svelte": "^5.55.4",
+ "vite": "^8.0.8"
},
"dependencies": {
"svelte-i18n": "^4.0.1",
- "svelte-spa-router": "^5.0.0"
+ "svelte-spa-router": "^5.0.1"
}
}
diff --git a/src/pinky_calendar/oauth.py b/src/pinky_calendar/oauth.py
index 650da029..5285edd9 100644
--- a/src/pinky_calendar/oauth.py
+++ b/src/pinky_calendar/oauth.py
@@ -53,10 +53,15 @@ def exchange_code(
client_id: str,
client_secret: str,
code: str,
- state: str, # noqa: ARG001 — validated by caller
+ state: str, # noqa: ARG001 — caller is responsible for validating state first
) -> dict:
"""Exchange an authorisation code for access + refresh tokens.
+ SECURITY: Callers MUST validate `state` against a previously issued,
+ unexpired, unconsumed nonce before invoking this function. Passing an
+ attacker-controlled `state` here without prior validation re-opens the
+ CSRF / account-linking hole fixed by #287.
+
Returns:
dict with keys: access_token, refresh_token, expiry (datetime | None).
"""
diff --git a/src/pinky_calendar/server.py b/src/pinky_calendar/server.py
index 8a2ab3f7..21ca7415 100644
--- a/src/pinky_calendar/server.py
+++ b/src/pinky_calendar/server.py
@@ -91,11 +91,10 @@ def get_events(
"Set CALDAV_URL, CALDAV_USERNAME, CALDAV_PASSWORD."
)
- now = datetime.now(tz=timezone.utc)
- start = _parse_dt(start_date) if start_date else now
- end = _parse_dt(end_date) if end_date else start + timedelta(days=days)
-
try:
+ now = datetime.now(tz=timezone.utc)
+ start = _parse_dt(start_date) if start_date else now
+ end = _parse_dt(end_date) if end_date else start + timedelta(days=days)
events = a.get_events(start, end)
return json.dumps([
{
@@ -109,6 +108,8 @@ def get_events(
}
for e in events
])
+ except ValueError as e:
+ return _err(f"Invalid date: {e}")
except Exception as e:
return _err(f"Failed to fetch events: {e}")
diff --git a/src/pinky_daemon/agent_registry.py b/src/pinky_daemon/agent_registry.py
index 9dbf4e9d..bd9e823b 100644
--- a/src/pinky_daemon/agent_registry.py
+++ b/src/pinky_daemon/agent_registry.py
@@ -184,6 +184,7 @@ class Agent:
retired_at: float = 0.0 # When was this agent retired
working_status: str = "idle" # idle, working, offline
working_status_updated_at: float = 0.0 # When working_status last changed
+ last_seen_at: float = 0.0 # Server-side presence: updated on delivery/turn completion
provider_url: str = "" # e.g. "http://localhost:11434" for Ollama, empty = Anthropic default
provider_key: str = "" # API key override, empty = use ANTHROPIC_API_KEY env var
provider_model: str = "" # model name override (e.g. "llama3.2"), empty = use agent.model
@@ -241,6 +242,7 @@ def to_dict(self) -> dict:
"retired_at": self.retired_at,
"working_status": self.working_status,
"working_status_updated_at": self.working_status_updated_at,
+ "last_seen_at": self.last_seen_at,
"provider_url": self.provider_url,
"provider_key": self.provider_key,
"provider_model": self.provider_model,
@@ -726,6 +728,7 @@ def _migrate(self) -> None:
("disallowed_tools", "TEXT NOT NULL DEFAULT '[]'"),
("thinking_effort", "TEXT NOT NULL DEFAULT 'medium'"),
("watchdog_config", "TEXT NOT NULL DEFAULT '{}'"),
+ ("last_seen_at", "REAL NOT NULL DEFAULT 0"),
]
for col, typedef in migrations:
if col not in existing:
@@ -1058,7 +1061,7 @@ def register(self, name: str, **kwargs) -> Agent:
"librarian_enabled, librarian_schedule, "
"working_status, working_status_updated_at, "
"provider_url, provider_key, provider_model, provider_ref, "
- "disallowed_tools, thinking_effort, watchdog_config"
+ "disallowed_tools, thinking_effort, watchdog_config, last_seen_at"
)
def get(self, name: str) -> Agent | None:
@@ -1127,6 +1130,12 @@ def restore(self, name: str) -> bool:
return True
return False
+ def stamp_last_seen(self, name: str, ts: float | None = None) -> None:
+ """Server-side presence: stamp agents.last_seen_at. Agent-agnostic."""
+ ts = ts if ts is not None else time.time()
+ self._db.execute("UPDATE agents SET last_seen_at = ? WHERE name = ?", (ts, name))
+ self._db.commit()
+
def set_working_status(self, name: str, status: str) -> bool:
"""Update an agent's working status (idle, working, offline)."""
if status not in ("idle", "working", "offline"):
@@ -2581,6 +2590,7 @@ def _row_to_agent(self, row: tuple) -> Agent:
disallowed_tools=json.loads(row[43]) if len(row) > 43 and row[43] else [],
thinking_effort=row[44] if len(row) > 44 and row[44] else "medium",
watchdog_config=json.loads(row[45]) if len(row) > 45 and row[45] else {},
+ last_seen_at=row[46] if len(row) > 46 else 0.0,
)
# ── Cost Tracking ──────────────────────────────────────
diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py
index 27669145..4337276a 100644
--- a/src/pinky_daemon/api.py
+++ b/src/pinky_daemon/api.py
@@ -2491,6 +2491,7 @@ async def _start_streaming_session(
"conversation_store": store,
"cost_callback": _make_cost_callback(agents),
"analytics_store": analytics,
+ "registry": agents,
}
if is_codex:
init_kwargs["stream_event_callback"] = await _make_streaming_event_callback(agent_name, label)
@@ -2942,7 +2943,9 @@ async def _trigger_librarian() -> None:
except Exception:
_git_branch = "unknown"
import datetime as _dt
- _now = _dt.datetime.now()
+ # UTC so the version string is deterministic regardless of where the
+ # daemon runs (was previously implicit local-tz). #294
+ _now = _dt.datetime.now(_dt.timezone.utc)
_pinky_version = f"{_now.strftime('%y')}.{int(_now.strftime('%m')):02d}.{_git_hash}"
if not os.environ.get("PINKY_SESSION_SECRET", "").strip():
@@ -4949,6 +4952,84 @@ async def google_auth_url():
"session": session_id,
}
+ # ── Legacy direct-Google OAuth state lifecycle (#287) ─────────
+ # Keys: GOOGLE_OAUTH_STATE_{state_nonce} → ISO-8601 issued timestamp.
+ # State nonces are single-use with a 10-minute TTL; consumed by the
+ # callback handler before the code exchange is attempted.
+ _google_oauth_state_ttl_sec = 600
+ _google_oauth_state_prefix = "GOOGLE_OAUTH_STATE_"
+
+ def _issue_google_oauth_state(nonce: str) -> None:
+ from datetime import datetime, timezone
+ agents.set_setting(
+ f"{_google_oauth_state_prefix}{nonce}",
+ datetime.now(tz=timezone.utc).isoformat(),
+ )
+
+ def _consume_google_oauth_state(nonce: str) -> str:
+ """Validate + delete a state nonce. Returns '' on success, else error reason.
+
+ Callers MUST treat any non-empty return as a hard rejection — the token
+ exchange must not proceed. Fails closed on every error path: the nonce
+ is considered consumed only if the DELETE observably removed a row.
+
+ Race semantics: if two callbacks fire concurrently with the same nonce
+ (one legitimate, one forged replay), exactly one `delete_setting()`
+ returns True and is accepted; the loser sees False and is rejected
+ as "unknown or replayed state".
+ """
+ from datetime import datetime, timezone
+ if not nonce:
+ return "missing state parameter"
+ key = f"{_google_oauth_state_prefix}{nonce}"
+ issued_raw = agents.get_setting(key)
+ if not issued_raw:
+ return "unknown or replayed state"
+ # DELETE is the authoritative consume step: its return value tells us
+ # whether *we* were the one to remove the row. If False, another
+ # caller beat us to it → treat as replay. If it raises, fail closed.
+ try:
+ deleted = agents.delete_setting(key)
+ except Exception:
+ return "could not consume state"
+ if not deleted:
+ return "unknown or replayed state"
+ try:
+ issued = datetime.fromisoformat(issued_raw)
+ except ValueError:
+ return "corrupt state record"
+ # Reject naive timestamps — `datetime.fromisoformat` happily accepts
+ # ISO strings without a tz suffix and returns a naive datetime, and
+ # subtracting a naive dt from an aware `now()` raises TypeError.
+ # We only ever *issue* aware UTC timestamps, so a naive record here
+ # implies corruption or tampering.
+ if issued.tzinfo is None:
+ return "corrupt state record"
+ age_sec = (datetime.now(tz=timezone.utc) - issued).total_seconds()
+ if age_sec > _google_oauth_state_ttl_sec:
+ return "expired state"
+ if age_sec < 0:
+ return "state issued in the future"
+ return ""
+
+ @app.get("/calendar/google/direct-auth-url")
+ async def google_direct_auth_url():
+ """Generate a direct-Google OAuth auth URL with a persisted state nonce.
+
+ Used by the backward-compat flow where a user has registered their own
+ Google Cloud client credentials and `/calendar/google/callback` as the
+ redirect URI. The state nonce is persisted here so the callback can
+ validate it (CSRF defense — see #287).
+ """
+ store = _google_token_store()
+ client_id, client_secret = store.get_client_credentials()
+ if not client_id or not client_secret:
+ raise HTTPException(400, "Google client credentials not configured")
+ from pinky_calendar.oauth import get_auth_url
+ auth_url, state = get_auth_url(client_id, client_secret)
+ _issue_google_oauth_state(state)
+ return {"auth_url": auth_url, "state": state}
+
@app.get("/calendar/google/fetch-token")
async def fetch_google_token(session: str):
"""Retrieve tokens from pinkybot.ai proxy after OAuth completes."""
@@ -4984,9 +5065,29 @@ async def fetch_google_token(session: str):
return {"connected": True}
@app.get("/calendar/google/callback")
- async def google_callback(code: str, state: str):
- """Handle the OAuth2 redirect (backward-compat for users with own credentials)."""
+ async def google_callback(code: str = "", state: str = ""):
+ """Handle the OAuth2 redirect (backward-compat for users with own credentials).
+
+ Requires a previously issued + unexpired + unconsumed state nonce
+ (see `/calendar/google/direct-auth-url`). Missing / unknown / replayed /
+ expired states are rejected before any token exchange happens, which
+ closes the CSRF / account-linking hole described in #287.
+ """
from fastapi.responses import HTMLResponse
+ state_error = _consume_google_oauth_state(state)
+ if state_error:
+ return HTMLResponse(
+ f"
OAuth state validation failed: {state_error}.
"
+ "Please retry from Settings — do not re-use an old link.
"
+ "",
+ status_code=400,
+ )
+ if not code:
+ return HTMLResponse(
+ "OAuth error: missing authorization code.
"
+ "",
+ status_code=400,
+ )
store = _google_token_store()
client_id, client_secret = store.get_client_credentials()
if not client_id or not client_secret:
@@ -5173,19 +5274,35 @@ def _agent_presence(agent_name: str) -> dict:
live = broker.get_live_agents()
streaming = agent_name in live
hb = agents.get_latest_heartbeat(agent_name)
+ agent_obj = agents.get(agent_name)
+ hb_ts = hb.timestamp if hb else 0
+ server_ts = agent_obj.last_seen_at if agent_obj else 0
+ last_seen = max(server_ts, hb_ts)
+ now = time.time()
if streaming:
status = "online"
- elif hb:
- age = time.time() - hb.timestamp
+ elif hb and hb_ts >= server_ts:
+ # Heartbeat is the freshest liveness signal — honor its status field.
+ age = now - hb.timestamp
if hb.status == "alive" and age < 300:
status = "online"
elif hb.status == "alive" and age < 1800:
status = "idle"
else:
status = "offline"
+ elif server_ts > 0:
+ # Server-stamped activity (turn completion, delivered inter-agent msg, etc.)
+ # is authoritative liveness evidence for agents that don't emit heartbeats.
+ age = now - server_ts
+ if age < 300:
+ status = "online"
+ elif age < 1800:
+ status = "idle"
+ else:
+ status = "offline"
else:
status = "unknown"
- return {"status": status, "streaming": streaming, "last_seen": hb.timestamp if hb else 0}
+ return {"status": status, "streaming": streaming, "last_seen": last_seen}
@app.get("/agents/presence")
async def get_all_agent_presence():
@@ -6940,13 +7057,14 @@ async def chat_with_agent(name: str, req: dict, session: str = "main"):
raise HTTPException(503, f"Agent '{name}' session '{label}' could not be started")
# Format with metadata like broker messages
- from datetime import datetime
+ from datetime import datetime, timezone
from zoneinfo import ZoneInfo
tz_str = agents.get_default_timezone()
try:
ts = datetime.now(ZoneInfo(tz_str)).strftime(f"%Y-%m-%d %H:%M:%S {tz_str}")
except Exception:
- ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
+ # datetime.utcnow() is deprecated in Python 3.12. #294
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
prompt = f"[web | dm | Admin | web | {ts}]\n{content}"
await streaming.send(prompt, platform="web", chat_id="web")
@@ -7000,13 +7118,14 @@ async def upload_file_to_agent(name: str, file: UploadFile):
size = len(content_bytes)
# Route to agent as a message with file attachment
- from datetime import datetime
+ from datetime import datetime, timezone
from zoneinfo import ZoneInfo
tz_str = agents.get_default_timezone()
try:
ts = datetime.now(ZoneInfo(tz_str)).strftime(f"%Y-%m-%d %H:%M:%S {tz_str}")
except Exception:
- ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
+ # datetime.utcnow() is deprecated in Python 3.12. #294
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
msg = BrokerMessage(
platform="web",
diff --git a/src/pinky_daemon/broker.py b/src/pinky_daemon/broker.py
index b5eab626..94b9ae5c 100644
--- a/src/pinky_daemon/broker.py
+++ b/src/pinky_daemon/broker.py
@@ -808,6 +808,11 @@ async def _route_streaming(self, agent_name: str, message: BrokerMessage) -> Non
message_id=message.message_id,
agent_hint=hint,
)
+ # Server-side presence: successful inbound delivery = agent pipe is working
+ try:
+ self._registry.stamp_last_seen(agent_name)
+ except Exception as e:
+ _log(f"broker: stamp_last_seen failed for {agent_name}: {e}")
# Start typing indicator for Telegram chats
if message.chat_id:
await self._start_typing(agent_name, message.platform, message.chat_id, streaming)
@@ -828,6 +833,11 @@ async def inject_agent_message(
ts = datetime.now(tz.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
prompt = f"[agent | {from_agent} | internal | {ts}]\n{message}"
await streaming.send(prompt)
+ # Server-side presence: successful delivery = agent is reachable
+ try:
+ self._registry.stamp_last_seen(to_agent)
+ except Exception as e:
+ _log(f"broker: stamp_last_seen failed for {to_agent}: {e}")
self._stats["routed"] += 1
_log(f"broker: injected agent message {from_agent} -> {to_agent}")
return True
diff --git a/src/pinky_daemon/codex_session.py b/src/pinky_daemon/codex_session.py
index 4d99a1f2..ae1dc5bf 100644
--- a/src/pinky_daemon/codex_session.py
+++ b/src/pinky_daemon/codex_session.py
@@ -62,6 +62,7 @@ def __init__(
cost_callback=None, # fn(agent_name, cost_usd, input_tokens, output_tokens, session_id)
stream_event_callback=None, # async fn(event: dict) for incremental UI streaming
analytics_store=None,
+ registry=None, # AgentRegistry — for server-side presence stamping
) -> None:
self._config = config
self._response_callback = response_callback
@@ -69,6 +70,7 @@ def __init__(
self._conversation_store = conversation_store
self._stream_event_callback = stream_event_callback
self._analytics_store = analytics_store
+ self._registry = registry
self._connected = False
self._processing = False # True while a codex exec is running
self._message_queue: asyncio.Queue[tuple[str, str, str, str]] = asyncio.Queue()
@@ -680,6 +682,7 @@ async def _handle_event(self, event: dict, result: CodexTurnResult) -> None:
"cached_input_tokens": result.cached_input_tokens,
},
)
+ self._stamp_last_seen()
await self._emit_stream_event({
"type": "turn_completed",
"agent": self.agent_name,
@@ -697,6 +700,7 @@ async def _handle_event(self, event: dict, result: CodexTurnResult) -> None:
err_msg = err.get("message", "turn failed")
result.errors.append(err_msg)
self._analytics_log_activity("turn_failed", metadata={"error": err_msg})
+ self._stamp_last_seen()
await self._emit_stream_event({
"type": "turn_failed",
"agent": self.agent_name,
@@ -708,6 +712,7 @@ async def _handle_event(self, event: dict, result: CodexTurnResult) -> None:
err_msg = event.get("message", "unknown error")
result.errors.append(err_msg)
self._analytics_log_activity("turn_error", metadata={"error": err_msg})
+ self._stamp_last_seen()
await self._emit_stream_event({
"type": "turn_error",
"agent": self.agent_name,
@@ -933,6 +938,15 @@ def _analytics_log_activity(self, event_type: str, *, metadata: dict | None = No
except Exception as e:
_log(f"codex[{self.agent_name}]: analytics activity failed: {e}")
+ def _stamp_last_seen(self) -> None:
+ """Server-side presence: stamp agent last_seen_at (agent-agnostic)."""
+ if not self._registry:
+ return
+ try:
+ self._registry.stamp_last_seen(self.agent_name)
+ except Exception as e:
+ _log(f"codex[{self.agent_name}]: stamp_last_seen failed: {e}")
+
def _analytics_log_turn_usage(
self,
*,
diff --git a/src/pinky_daemon/streaming_session.py b/src/pinky_daemon/streaming_session.py
index fd910242..03963730 100644
--- a/src/pinky_daemon/streaming_session.py
+++ b/src/pinky_daemon/streaming_session.py
@@ -16,7 +16,7 @@
import time
import zoneinfo
from dataclasses import dataclass, field
-from datetime import datetime
+from datetime import datetime, timezone
from pathlib import Path
from pinky_daemon.sessions import SessionUsage
@@ -173,12 +173,14 @@ def __init__(
conversation_store=None, # ConversationStore for history logging
cost_callback=None, # fn(agent_name, cost_usd, input_tokens, output_tokens, session_id)
analytics_store=None,
+ registry=None, # AgentRegistry — for server-side presence stamping
) -> None:
self._config = config
self._response_callback = response_callback
self._cost_callback = cost_callback # Sync callback to persist costs
self._conversation_store = conversation_store
self._analytics_store = analytics_store
+ self._registry = registry
self._client = None
self._reader_task: asyncio.Task | None = None
self._connected = False
@@ -295,7 +297,11 @@ async def connect(self) -> None:
_now = datetime.now(_tz)
time_str = _now.strftime("%A, %B %-d, %Y at %-I:%M %p %Z")
except Exception:
- time_str = datetime.now().strftime("%A, %B %-d, %Y at %-I:%M %p UTC")
+ # Fallback labelled "UTC" was previously using implicit local-tz
+ # datetime.now() — fix to actual UTC. #294
+ time_str = datetime.now(timezone.utc).strftime(
+ "%A, %B %-d, %Y at %-I:%M %p UTC"
+ )
tools_hint = (
"You have explicit pinky-messaging outreach tools: "
@@ -548,6 +554,7 @@ async def _reader_loop(self) -> None:
"errors": str(msg.errors or "")[:200],
},
)
+ self._stamp_last_seen()
self._last_response = ""
self._current_activity = ""
self._activity_log = []
@@ -663,6 +670,8 @@ async def _reader_loop(self) -> None:
"cached_input_tokens": agg_cached,
},
)
+ # Stamp on any turn end (complete or error) — proves pipe is live
+ self._stamp_last_seen()
# Log assistant response to conversation store with metadata
if self._last_response and self._conversation_store:
@@ -960,6 +969,15 @@ def _analytics_log_activity(
except Exception as e:
_log(f"streaming[{self.agent_name}]: analytics activity failed: {e}")
+ def _stamp_last_seen(self) -> None:
+ """Server-side presence: stamp agent last_seen_at (agent-agnostic)."""
+ if not self._registry:
+ return
+ try:
+ self._registry.stamp_last_seen(self.agent_name)
+ except Exception as e:
+ _log(f"streaming[{self.agent_name}]: stamp_last_seen failed: {e}")
+
def _analytics_log_turn_usage(
self,
*,
diff --git a/src/pinky_daemon/voice_engine.py b/src/pinky_daemon/voice_engine.py
index fc4b2149..d91a2e8b 100644
--- a/src/pinky_daemon/voice_engine.py
+++ b/src/pinky_daemon/voice_engine.py
@@ -366,7 +366,16 @@ async def finalize_call(
broker_send: Callable | None = None,
api_key: str = "",
) -> None:
- """Post-call finalization: build transcript, Opus review, save artifact, notify."""
+ """Post-call finalization: build transcript, Opus review, save artifact, notify.
+
+ Callers in the WS disconnect path can hit a race where the session has
+ already been cleaned up by the time we try to finalize. We accept None
+ here as a defensive belt-and-suspenders guard so a stray call can't
+ raise AttributeError on `session.call_sid`. (#286)
+ """
+ if session is None:
+ _log("voice: finalize_call called with None session — skipping")
+ return
_log(f"voice: finalizing call {session.call_sid}")
# Get call request for goal context
diff --git a/src/pinky_daemon/voice_routes.py b/src/pinky_daemon/voice_routes.py
index 226a6142..b5712c3a 100644
--- a/src/pinky_daemon/voice_routes.py
+++ b/src/pinky_daemon/voice_routes.py
@@ -865,15 +865,27 @@ async def conversationrelay_ws(ws: WebSocket, call_session_id: str):
session.id, status="completed", ended_at=time.time()
)
- # Finalize call in background (Opus review + artifact + notify)
+ # Finalize call in background (Opus review + artifact + notify).
+ # get_session() can return None under session-expiry races (session
+ # was cleaned up between the update above and this lookup, or it was
+ # never committed). Check explicitly — handing None to finalize_call()
+ # would raise AttributeError on session.call_sid. (#286)
if messages:
- asyncio.create_task(
- finalize_call(
- _voice_store.get_session(call_session_id),
- _voice_store, _agents, _broker_send,
- api_key=_api_key,
+ finalize_session = _voice_store.get_session(call_session_id)
+ if finalize_session is None:
+ _log(
+ f"voice: cannot finalize call — session {call_session_id} "
+ f"no longer in store (likely expired or cleaned up); "
+ f"skipping Opus review + artifact"
+ )
+ else:
+ asyncio.create_task(
+ finalize_call(
+ finalize_session,
+ _voice_store, _agents, _broker_send,
+ api_key=_api_key,
+ )
)
- )
_log(f"voice: WS closed for session {call_session_id}")
diff --git a/src/pinky_memory/kg_extractor.py b/src/pinky_memory/kg_extractor.py
index e47ddd9d..46ba7158 100644
--- a/src/pinky_memory/kg_extractor.py
+++ b/src/pinky_memory/kg_extractor.py
@@ -420,6 +420,10 @@ def _resolve_functional_conflict(
limit=10,
)
+ # Cached once per call — previously computed per-iteration inside the
+ # loop even though the value never changes within one call. #294
+ _now_ymd = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+
for old in existing:
# Only consider triples where subject matches exactly and object differs
if not (
@@ -446,9 +450,7 @@ def _resolve_functional_conflict(
})
return False # Don't insert this triple
- valid_to = new_valid_from or datetime.now(
- timezone.utc
- ).strftime("%Y-%m-%d")
+ valid_to = new_valid_from or _now_ymd
self._store.kg_invalidate(
old["subject"], old["predicate"], old["object"],
valid_to=valid_to,
diff --git a/src/pinky_memory/server.py b/src/pinky_memory/server.py
index c37498a1..658d39a3 100644
--- a/src/pinky_memory/server.py
+++ b/src/pinky_memory/server.py
@@ -1,12 +1,15 @@
from __future__ import annotations
import json
+import math
import sys
from collections.abc import Callable
from typing import TYPE_CHECKING
from mcp.server.fastmcp import FastMCP
+from pinky_memory.embeddings import NoOpEmbeddingClient
+from pinky_memory.store import InvalidQueryEmbeddingError
from pinky_memory.types import (
PRESET_NAMES,
IntrospectInput,
@@ -18,7 +21,7 @@
)
if TYPE_CHECKING:
- from pinky_memory.embeddings import EmbeddingClient, NoOpEmbeddingClient
+ from pinky_memory.embeddings import EmbeddingClient
from pinky_memory.store import ReflectionStore
@@ -27,6 +30,61 @@ def _log(msg: str) -> None:
print(msg, file=sys.stderr, flush=True)
+def _safe_embed(
+ embedder: "EmbeddingClient | NoOpEmbeddingClient",
+ text: str,
+ op: str,
+) -> list[float]:
+ """Call embedder.embed() with loud failure logging.
+
+ Returns the embedding vector, or [] on failure. Distinguishes four
+ cases for the logs:
+ - NoOp embedder (no OPENAI_API_KEY): silent, expected downgrade
+ - embed() raised: log with exception type + message
+ - embed() returned [] from a non-NoOp client: log as degraded
+ - embed() returned a zero-norm or non-finite vector: log as degraded
+ and coerce to [] so downstream search can treat it as "no embedding"
+ rather than silently storing/querying an unusable vector
+ (see #285 zero-norm query handling).
+ """
+ if isinstance(embedder, NoOpEmbeddingClient):
+ return []
+ try:
+ embedding = embedder.embed(text)
+ except Exception as exc: # noqa: BLE001 — we want any embed failure visible
+ _log(f"[{op}] embedder.embed() raised {type(exc).__name__}: {exc}")
+ return []
+ if not embedding:
+ _log(
+ f"[{op}] embedder.embed() returned empty vector with non-NoOp client — "
+ f"embeddings are degraded; semantic search will miss this entry"
+ )
+ return []
+ # Guard against non-empty-but-unusable vectors: all zeros, NaN, or inf.
+ # These pass `if not embedding` but are semantically equivalent to a
+ # failed embed — storing them creates rows that zero-norm-skip in search
+ # (see ReflectionStore._search_by_numpy) with no signal upstream.
+ try:
+ norm_sq = 0.0
+ has_bad = False
+ for x in embedding:
+ if not math.isfinite(x):
+ has_bad = True
+ break
+ norm_sq += x * x
+ if has_bad or norm_sq == 0.0:
+ _log(
+ f"[{op}] embedder.embed() returned a degenerate vector "
+ f"(zero-norm or non-finite) — treating as degraded; "
+ f"semantic search will miss this entry"
+ )
+ return []
+ except Exception as exc: # noqa: BLE001 — defensive
+ _log(f"[{op}] embedding validation raised {type(exc).__name__}: {exc}")
+ return []
+ return embedding
+
+
def create_server(
store: ReflectionStore | None = None,
embedder: EmbeddingClient | NoOpEmbeddingClient | None = None,
@@ -89,8 +147,9 @@ def reflect(
source_message_ids=source_message_ids or [],
)
- # Generate embedding
- embedding = embedder.embed(input_data.content)
+ # Generate embedding (tolerant: empty / raised embeddings downgrade
+ # to keyword-only search rather than aborting storage).
+ embedding = _safe_embed(embedder, input_data.content, "reflect")
# Build reflection
ref = Reflection(
@@ -121,6 +180,7 @@ def reflect(
"type": ref.type.value,
"salience": ref.salience,
"stored": True,
+ "embedded": bool(embedding),
})
@mcp.tool()
@@ -150,18 +210,28 @@ def recall(
s = _get_store()
if input_data.query:
- # Try vector search first
- query_embedding = embedder.embed(input_data.query)
+ # Try vector search first (tolerant of embedder failures / degrades).
+ query_embedding = _safe_embed(embedder, input_data.query, "recall")
if query_embedding:
- results = s.search_by_embedding(
- query_embedding=query_embedding,
- limit=input_data.limit,
- active_only=input_data.active_only,
- type_filter=input_data.type,
- project_filter=input_data.project,
- min_weight=input_data.min_weight,
- entity_filter=input_data.entity,
- )
+ try:
+ results = s.search_by_embedding(
+ query_embedding=query_embedding,
+ limit=input_data.limit,
+ active_only=input_data.active_only,
+ type_filter=input_data.type,
+ project_filter=input_data.project,
+ min_weight=input_data.min_weight,
+ entity_filter=input_data.entity,
+ )
+ except InvalidQueryEmbeddingError as exc:
+ # Broken query embedding (zero-norm/empty). Log loudly and
+ # fall back to keyword search so the user still gets a
+ # meaningful response instead of a silent empty result.
+ _log(
+ f"[recall] invalid query embedding ({exc}); "
+ f"falling back to keyword search"
+ )
+ results = []
# Fall back to keyword search if no vector results
if not results:
diff --git a/src/pinky_memory/store.py b/src/pinky_memory/store.py
index 010743ed..a8cea281 100644
--- a/src/pinky_memory/store.py
+++ b/src/pinky_memory/store.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
+import logging
import shutil
import sqlite3
import struct
@@ -23,6 +24,18 @@
if TYPE_CHECKING:
pass
+logger = logging.getLogger(__name__)
+
+
+class InvalidQueryEmbeddingError(ValueError):
+ """Raised when a query embedding cannot be used for similarity search.
+
+ Distinguishes a broken/degenerate query (all zeros, empty, wrong shape)
+ from a legitimate 'no matches found' result. Callers should catch and
+ either log + fall back to keyword search or surface to the user.
+ """
+
+
# ── Memory linking constants ──
LINK_THRESHOLD = 0.78
@@ -114,6 +127,9 @@ def __init__(self, db_path: str = "data/reflections.db") -> None:
self._lock = threading.RLock()
self._vec_available = False
self._vec_dimensions = 0
+ # Runtime counter of sqlite-vec → numpy fallbacks. Surfaced in
+ # introspect() so health checks can detect a degraded vec backend.
+ self._vec_fallback_count = 0
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
@@ -144,8 +160,12 @@ def _init_schema(self) -> None:
"ON reflections(source_session_id)"
)
self._conn.commit()
- except Exception:
- pass
+ except sqlite3.OperationalError as e: # pragma: no cover — rare migration failure
+ # Index creation is non-critical (queries fall back to full scan).
+ # Log so an admin can diagnose silent degradation. #295
+ logger.warning(
+ "failed to create idx_reflections_source_session: %s", e
+ )
# Migrate: spaced review columns (memory review system)
self._migrate_add_column("next_review_date", "TEXT DEFAULT NULL")
self._migrate_add_column("review_interval_days", "INTEGER DEFAULT 7")
@@ -164,7 +184,13 @@ def _init_schema(self) -> None:
self._conn.executescript(_FTS5_TRIGGERS)
self._conn.commit()
self._fts5_available = True
- except Exception:
+ except sqlite3.OperationalError as e:
+ # FTS5 extension not compiled into this sqlite build. Search
+ # falls back to LIKE — log so admins can diagnose slow queries. #295
+ logger.warning(
+ "FTS5 initialization failed — keyword search degraded to LIKE: %s",
+ e,
+ )
self._fts5_available = False
# sqlite-vec virtual table (separate try — graceful if not available)
self._init_vec()
@@ -236,8 +262,16 @@ def _init_vec(self) -> None:
self._conn.enable_load_extension(True)
self._conn.load_extension(ext_path)
self._conn.enable_load_extension(False)
- except Exception:
- return # sqlite-vec not available — fall back to numpy
+ except (ImportError, sqlite3.OperationalError, AttributeError) as e:
+ # sqlite-vec not installed (ImportError), extension loading denied
+ # by the sqlite build (OperationalError), or loadable_path() absent
+ # (AttributeError on older sqlite_vec). Semantic search falls back
+ # to in-process numpy — log so admins can diagnose degraded search. #295
+ logger.info(
+ "sqlite-vec unavailable — vector search will use numpy fallback: %s",
+ e,
+ )
+ return
# Detect dimensions from existing embeddings
dims = self._detect_embedding_dimensions()
@@ -256,7 +290,14 @@ def _init_vec(self) -> None:
f"USING vec0(embedding float[{dims}] distance_metric=cosine)"
)
self._conn.commit()
- except Exception:
+ except sqlite3.OperationalError as e:
+ # vec0 table creation failed — leave _vec_available = False so
+ # downstream paths use the numpy fallback. #295
+ logger.warning(
+ "failed to create reflections_vec vec0 table (dims=%d): %s",
+ dims,
+ e,
+ )
return
# Vec table created successfully — mark as available
@@ -274,16 +315,36 @@ def _create_vec_table(self, dims: int) -> None:
)
self._conn.commit()
self._vec_dimensions = dims
- except Exception:
- pass
+ except sqlite3.OperationalError as e:
+ # vec0 table creation failed — caller will keep numpy fallback. #295
+ logger.warning(
+ "failed to create reflections_vec table (dims=%d): %s", dims, e
+ )
def _detect_embedding_dimensions(self) -> int:
- """Detect embedding dimensions from the first non-empty embedding in the DB."""
- row = self._conn.execute(
- "SELECT embedding FROM reflections WHERE embedding != '[]' LIMIT 1"
- ).fetchone()
- if row:
- emb = json.loads(row[0])
+ """Detect embedding dimensions from the first valid embedding in the DB.
+
+ Scans until a parseable embedding is found; a single corrupt row no
+ longer aborts startup. Malformed rows are logged with their id so
+ they can be reviewed or GC'd manually.
+ """
+ rows = self._conn.execute(
+ "SELECT id, embedding FROM reflections WHERE embedding != '[]' "
+ "ORDER BY created_at DESC LIMIT 50"
+ ).fetchall()
+ for row in rows:
+ rid = row[0] if not isinstance(row, sqlite3.Row) else row["id"]
+ raw = row[1] if not isinstance(row, sqlite3.Row) else row["embedding"]
+ try:
+ emb = json.loads(raw)
+ except (ValueError, TypeError) as exc:
+ logger.warning(
+ "reflection %s has malformed embedding JSON (%s); skipping "
+ "during dimension detection",
+ rid,
+ exc,
+ )
+ continue
if emb:
return len(emb)
return 0
@@ -294,15 +355,26 @@ def _backfill_vec(self) -> None:
return
# Find reflections with embeddings that are NOT yet in the vec table
rows = self._conn.execute(
- "SELECT r.rowid, r.embedding FROM reflections r "
+ "SELECT r.rowid, r.id, r.embedding FROM reflections r "
"WHERE r.embedding != '[]' AND r.rowid NOT IN "
"(SELECT rowid FROM reflections_vec)"
).fetchall()
if not rows:
return
inserted = 0
+ corrupt = 0
for row in rows:
- emb = json.loads(row[1])
+ try:
+ emb = json.loads(row[2])
+ except (ValueError, TypeError) as exc:
+ logger.warning(
+ "reflection %s has malformed embedding JSON (%s); "
+ "skipping during vec backfill",
+ row[1],
+ exc,
+ )
+ corrupt += 1
+ continue
if len(emb) != self._vec_dimensions:
continue # skip mismatched dimensions
blob = struct.pack(f"{len(emb)}f", *emb)
@@ -312,11 +384,17 @@ def _backfill_vec(self) -> None:
(row[0], blob),
)
inserted += 1
- except Exception:
- pass # rowid conflict or other issue — skip
+ except sqlite3.Error as exc:
+ logger.debug(
+ "vec backfill skipped rowid=%s id=%s: %s",
+ row[0], row[1], exc,
+ )
if inserted:
self._conn.commit()
- print(f"[memory] vec_backfill_complete inserted={inserted} skipped={len(rows) - inserted}")
+ print(
+ f"[memory] vec_backfill_complete inserted={inserted} "
+ f"skipped={len(rows) - inserted} corrupt={corrupt}"
+ )
@staticmethod
def _embedding_to_blob(embedding: list[float]) -> bytes:
@@ -381,8 +459,14 @@ def insert(self, reflection: Reflection) -> Reflection:
"INSERT INTO reflections_vec(rowid, embedding) VALUES (?, ?)",
(cursor.lastrowid, blob),
)
- except Exception:
- pass # non-fatal — vector search degrades to numpy fallback
+ except sqlite3.OperationalError as e:
+ # vec insert failed — non-fatal, vector search degrades
+ # to numpy fallback for this row. #295
+ logger.debug(
+ "vec insert failed for rowid=%s: %s",
+ cursor.lastrowid,
+ e,
+ )
self._conn.commit()
return reflection
@@ -538,7 +622,26 @@ def search_by_embedding_scored(
final_score = similarity * weight * (recency_factor ^ hours_since_access)
If access_boost > 0, boosts weight on each accessed reflection.
If entity_filter is set, only returns reflections tagged with that entity.
+
+ Raises:
+ InvalidQueryEmbeddingError: if query_embedding is empty or has zero
+ norm. Distinguishes a broken embedding from a legitimate
+ "no matches" empty result.
"""
+ if not query_embedding:
+ logger.warning("search_by_embedding_scored: empty query embedding")
+ raise InvalidQueryEmbeddingError("query embedding is empty")
+
+ query_norm = float(np.linalg.norm(np.asarray(query_embedding, dtype=np.float32)))
+ if query_norm == 0.0:
+ logger.warning(
+ "search_by_embedding_scored: zero-norm query embedding (len=%d)",
+ len(query_embedding),
+ )
+ raise InvalidQueryEmbeddingError(
+ "query embedding has zero norm; cannot compute cosine similarity"
+ )
+
with self._lock:
if (
self._vec_available
@@ -581,12 +684,23 @@ def _search_by_vec(
"WHERE embedding MATCH ? AND k = ? ORDER BY distance",
(query_blob, fetch_k),
).fetchall()
- except Exception:
- # Fall back to numpy on any vec query failure
+ except Exception as exc:
+ # Fall back to numpy on any vec query failure. Log loudly —
+ # silent degradation here previously meant slower, differently
+ # ordered search with no signal that vec was broken.
+ self._vec_fallback_count += 1
+ logger.warning(
+ "sqlite-vec query failed (%s: %s) — falling back to numpy "
+ "scan (fallback #%d this process)",
+ type(exc).__name__,
+ exc,
+ self._vec_fallback_count,
+ )
return self._search_by_numpy(
query_embedding, limit, active_only,
type_filter, project_filter, min_weight,
recency_factor, access_boost, entity_filter,
+ type_exclude,
)
if not vec_rows:
@@ -719,7 +833,14 @@ def _search_by_numpy(
query_vec = np.array(query_embedding, dtype=np.float32)
query_norm = np.linalg.norm(query_vec)
if query_norm == 0:
- return []
+ logger.warning(
+ "search_by_embedding: zero-norm query embedding "
+ "(len=%d) — refusing to conflate with 'no matches'",
+ len(query_embedding),
+ )
+ raise InvalidQueryEmbeddingError(
+ "query embedding has zero norm; cannot compute cosine similarity"
+ )
now_dt = datetime.now(timezone.utc)
now = now_dt.isoformat()
@@ -933,8 +1054,9 @@ def _search_by_fts5(
try:
rows = self._conn.execute(sql, params).fetchall()
- except Exception:
- # FTS5 query syntax error — fall back to LIKE
+ except sqlite3.OperationalError as e:
+ # FTS5 query syntax error or missing table — fall back to LIKE. #295
+ logger.debug("FTS5 query failed, falling back to LIKE: %s", e)
return self._search_by_like(
query, limit, active_only, type_filter, project_filter, min_weight, entity_filter,
)
@@ -1121,7 +1243,9 @@ def _find_near_duplicate_vec(
"WHERE embedding MATCH ? AND k = 5 ORDER BY distance",
(query_blob,),
).fetchall()
- except Exception:
+ except sqlite3.OperationalError as e:
+ # vec query failed — fall back to numpy near-duplicate scan. #295
+ logger.debug("vec near-dup query failed, using numpy: %s", e)
return self._find_near_duplicate_numpy(embedding, threshold, active_only)
for rowid, distance in vec_rows:
@@ -1251,6 +1375,8 @@ def introspect(
"by_project": by_project,
"by_salience": by_salience,
"recent": recent,
+ "vec_available": self._vec_available,
+ "vec_fallback_count": self._vec_fallback_count,
}
@staticmethod
@@ -1360,8 +1486,12 @@ def gc_inactive(self, max_age_days: int = 30) -> int:
self._conn.execute(
"DELETE FROM reflections_vec WHERE rowid = ?", (rowid,)
)
- except Exception:
- pass
+ except sqlite3.OperationalError as e:
+ # vec row missing or write failure — non-fatal; main row
+ # is still deleted below. #295
+ logger.debug(
+ "vec delete failed for rowid=%s: %s", rowid, e
+ )
# Delete the reflections
placeholders = ",".join("?" for _ in delete_ids)
@@ -1446,7 +1576,10 @@ def consolidate_batch(
# LLM review band
try:
verdict = llm_classify(ref.content, candidate.content)
- except Exception:
+ except Exception as e: # noqa: BLE001 — user callable can raise anything
+ # LLM classifier is user-supplied; default to "different" on any
+ # error (network, rate limit, parse, etc.). #295
+ logger.warning("llm_classify failed, treating as different: %s", e)
verdict = "different"
if verdict == "same":
@@ -1503,7 +1636,9 @@ def _knn_vec(
"WHERE embedding MATCH ? AND k = ? ORDER BY distance",
(query_blob, fetch_k),
).fetchall()
- except Exception:
+ except sqlite3.OperationalError as e:
+ # vec query failed during consolidation — use numpy k-NN. #295
+ logger.debug("vec k-NN failed, using numpy: %s", e)
return self._knn_numpy(ref, k, exclude)
results = []
@@ -2126,8 +2261,9 @@ def rebuild_fts(self) -> int:
return -1
try:
self._conn.execute("DELETE FROM reflections_fts")
- except Exception:
- # FTS5 table itself is corrupt — nuke and recreate
+ except sqlite3.OperationalError as e:
+ # FTS5 table itself is corrupt — nuke and recreate. #295
+ logger.warning("FTS5 table corrupt, rebuilding: %s", e)
self._conn.executescript(
"DROP TRIGGER IF EXISTS reflections_ai;"
"DROP TRIGGER IF EXISTS reflections_ad;"
@@ -2181,8 +2317,10 @@ def reopen(self) -> None:
with self._lock:
try:
self._conn.close()
- except Exception:
- pass
+ except sqlite3.Error as e:
+ # Connection may already be closed — ignore and proceed to
+ # reopen below. #295
+ logger.debug("close on reopen failed (already closed?): %s", e)
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
@@ -2241,8 +2379,12 @@ def _migrate_kg_phase2(self) -> None:
f"ALTER TABLE kg_triples ADD COLUMN {col_name} {col_def}"
)
self._conn.commit()
- except Exception:
- pass # Column already exists
+ except sqlite3.OperationalError as e:
+ # Column already exists (idempotent migration) — expected on
+ # upgrade paths. #295
+ logger.debug(
+ "kg_triples column %s add skipped: %s", col_name, e
+ )
# Extraction log: tracks which reflections have been KG-processed
self._conn.executescript("""
diff --git a/src/pinky_outreach/discord.py b/src/pinky_outreach/discord.py
index d4b9fa0c..1d73d6df 100644
--- a/src/pinky_outreach/discord.py
+++ b/src/pinky_outreach/discord.py
@@ -37,7 +37,6 @@ def __init__(self, bot_token: str, *, timeout: float = 30.0) -> None:
base_url=self.BASE_URL,
headers={
"Authorization": f"Bot {bot_token}",
- "Content-Type": "application/json",
},
timeout=timeout,
)
@@ -108,7 +107,6 @@ def send_file(
f"/channels/{channel_id}/messages",
data={"content": content} if content else {},
files={"files[0]": (filename, f)},
- headers={"Content-Type": None}, # Let httpx set multipart headers
)
if resp.status_code >= 400:
diff --git a/src/pinky_outreach/telegram.py b/src/pinky_outreach/telegram.py
index 23f7d2ab..df249b6b 100644
--- a/src/pinky_outreach/telegram.py
+++ b/src/pinky_outreach/telegram.py
@@ -54,6 +54,10 @@ def _request(self, method: str, **params) -> dict:
return data.get("result", {})
+ def _media_data(self, **params) -> dict:
+ """Build multipart form data without sending Telegram null fields."""
+ return {k: v for k, v in params.items() if v is not None}
+
# ── Sending ──────────────────────────────────────────────
def send_message(
@@ -98,7 +102,11 @@ def send_photo(
with open(photo_path, "rb") as f:
resp = self._client.post(
url,
- data={"chat_id": chat_id, "caption": caption, "reply_to_message_id": reply_to_message_id},
+ data=self._media_data(
+ chat_id=chat_id,
+ caption=caption,
+ reply_to_message_id=reply_to_message_id,
+ ),
files={"photo": f},
)
data = resp.json()
@@ -132,7 +140,11 @@ def send_document(
with open(file_path, "rb") as f:
resp = self._client.post(
url,
- data={"chat_id": chat_id, "caption": caption, "reply_to_message_id": reply_to_message_id},
+ data=self._media_data(
+ chat_id=chat_id,
+ caption=caption,
+ reply_to_message_id=reply_to_message_id,
+ ),
files={"document": f},
)
data = resp.json()
@@ -166,7 +178,11 @@ def send_animation(
with open(file_path, "rb") as f:
resp = self._client.post(
url,
- data={"chat_id": chat_id, "caption": caption, "reply_to_message_id": reply_to_message_id},
+ data=self._media_data(
+ chat_id=chat_id,
+ caption=caption,
+ reply_to_message_id=reply_to_message_id,
+ ),
files={"animation": f},
)
data = resp.json()
diff --git a/src/pinky_outreach/whatsapp.py b/src/pinky_outreach/whatsapp.py
index 335be4f5..371f5264 100644
--- a/src/pinky_outreach/whatsapp.py
+++ b/src/pinky_outreach/whatsapp.py
@@ -246,6 +246,17 @@ def download_file(self, media_id: str, dest_dir: str = "/tmp/pinky_files") -> st
raise WhatsAppError("No download URL for media", 0)
# Step 2: Download the file
resp = self._client.get(url, headers={"Authorization": f"Bearer {self._token}"})
+ if resp.status_code >= 400:
+ message = f"Media download failed with HTTP {resp.status_code}"
+ code = resp.status_code
+ try:
+ error = resp.json().get("error", {})
+ except Exception:
+ error = {}
+ if error:
+ message = error.get("message", message)
+ code = error.get("code", code)
+ raise WhatsAppError(message, code)
ext = data.get("mime_type", "application/octet-stream").split("/")[-1].split(";")[0]
path = os.path.join(dest_dir, f"wa_{media_id}.{ext}")
with open(path, "wb") as f:
diff --git a/src/pinky_self/server.py b/src/pinky_self/server.py
index 9b944468..0b9a0098 100644
--- a/src/pinky_self/server.py
+++ b/src/pinky_self/server.py
@@ -18,6 +18,7 @@
import os
import sys
import urllib.error
+import urllib.parse
import urllib.request
from datetime import datetime, timezone
@@ -2138,7 +2139,12 @@ def kb_ingest(
@mcp.tool()
def kb_search(query: str, scope: str = "all") -> str:
"""Search the knowledge base. scope: all | raw | wiki."""
- result = _api("GET", f"/kb/search?q={query}&scope={scope}&limit=15")
+ params = urllib.parse.urlencode({
+ "q": query,
+ "scope": scope,
+ "limit": 15,
+ })
+ result = _api("GET", f"/kb/search?{params}")
if "error" in result:
return f"Error: {result['error']}"
@@ -2157,7 +2163,8 @@ def kb_search(query: str, scope: str = "all") -> str:
@mcp.tool()
def kb_get_wiki(topic: str) -> str:
"""Get a wiki page by slug (e.g. "topics/llm-knowledge-bases")."""
- result = _api("GET", f"/kb/wiki/{topic}?include_content=true")
+ topic_path = urllib.parse.quote(topic, safe="/")
+ result = _api("GET", f"/kb/wiki/{topic_path}?include_content=true")
if "error" in result:
return f"No wiki page found for: {topic}"
@@ -2217,7 +2224,8 @@ def kb_save_wiki(
source_list = [s.strip() for s in sources.split(",") if s.strip()] if sources else []
related_list = [r.strip() for r in related.split(",") if r.strip()] if related else []
- result = _api("PUT", f"/kb/wiki/{slug}", body={
+ slug_path = urllib.parse.quote(slug, safe="/")
+ result = _api("PUT", f"/kb/wiki/{slug_path}", body={
"title": title,
"content": content,
"sources": source_list,
@@ -2230,7 +2238,8 @@ def kb_save_wiki(
@mcp.tool()
def kb_delete_wiki(slug: str) -> str:
"""Delete a wiki page by slug."""
- result = _api("DELETE", f"/kb/wiki/{slug}")
+ slug_path = urllib.parse.quote(slug, safe="/")
+ result = _api("DELETE", f"/kb/wiki/{slug_path}")
if "error" in result:
return f"Error: {result['error']}"
return f"🗑️ Wiki page deleted: {slug}"
@@ -2238,7 +2247,8 @@ def kb_delete_wiki(slug: str) -> str:
@mcp.tool()
def kb_delete_raw(source_id: str) -> str:
"""Delete a raw KB source by ID (e.g. 'raw-2026-04-08-001')."""
- result = _api("DELETE", f"/kb/raw/{source_id}")
+ source_path = urllib.parse.quote(source_id, safe="")
+ result = _api("DELETE", f"/kb/raw/{source_path}")
if "error" in result:
return f"Error: {result['error']}"
return f"🗑️ Raw source deleted: {source_id}"
@@ -2272,7 +2282,8 @@ def kb_update_raw(
body["owner_notes"] = owner_notes
if not body:
return "No fields to update — provide at least one field."
- result = _api("PUT", f"/kb/raw/{source_id}", body=body)
+ source_path = urllib.parse.quote(source_id, safe="")
+ result = _api("PUT", f"/kb/raw/{source_path}", body=body)
if "error" in result:
return f"Error: {result['error']}"
return f"✅ Raw source updated: {source_id} — {result.get('title', '')}"
diff --git a/tests/test_agent_registry.py b/tests/test_agent_registry.py
index 6dca4166..286d7f43 100644
--- a/tests/test_agent_registry.py
+++ b/tests/test_agent_registry.py
@@ -128,6 +128,28 @@ def test_to_dict(self, registry):
assert d["model"] == "opus"
assert d["enabled"] is True
+ def test_stamp_last_seen_updates_column(self, registry):
+ registry.register("seen")
+ agent = registry.get("seen")
+ assert agent.last_seen_at == 0.0
+
+ registry.stamp_last_seen("seen", ts=1234567.0)
+ updated = registry.get("seen")
+ assert updated.last_seen_at == 1234567.0
+
+ def test_stamp_last_seen_default_ts_is_now(self, registry):
+ import time as _time
+ registry.register("seen2")
+ before = _time.time()
+ registry.stamp_last_seen("seen2")
+ after = _time.time()
+ updated = registry.get("seen2")
+ assert before <= updated.last_seen_at <= after
+
+ def test_stamp_last_seen_missing_agent_is_noop(self, registry):
+ # Should not raise — just affects zero rows.
+ registry.stamp_last_seen("ghost", ts=42.0)
+
class TestDirectives:
def test_add_directive(self, registry):
diff --git a/tests/test_api.py b/tests/test_api.py
index 3efc07f7..9b4d431c 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1438,6 +1438,67 @@ def test_all_agents_presence(self):
data = resp.json()
assert "agents" in data
+ def _make_client_with_registry(self):
+ """Return (client, registry) both pointing at the same in-api agents.db."""
+ from pinky_daemon.agent_registry import AgentRegistry
+ from pinky_daemon.api import create_api
+ fd, path = tempfile.mkstemp(suffix=".db")
+ os.close(fd)
+ app = create_api(max_sessions=10, default_working_dir="/tmp", db_path=path)
+ # create_api splits storage: agents live in {db_path}_agents.db
+ registry = AgentRegistry(db_path=path.replace(".db", "_agents.db"))
+ return TestClient(app), registry
+
+ def test_agent_presence_server_stamped_online(self):
+ """Non-heartbeat agent with fresh last_seen_at should be online, not unknown."""
+ client, registry = self._make_client_with_registry()
+ client.post("/agents", json={"name": "codex-a", "model": "opus"})
+ registry.stamp_last_seen("codex-a", ts=time.time())
+ resp = client.get("/agents/codex-a/presence")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["status"] == "online", f"expected online, got {data['status']}"
+ assert data["last_seen"] > 0
+ assert data["streaming"] is False
+
+ def test_agent_presence_server_stamped_idle(self):
+ """Non-heartbeat agent stamped 10min ago should be idle."""
+ client, registry = self._make_client_with_registry()
+ client.post("/agents", json={"name": "codex-b", "model": "opus"})
+ registry.stamp_last_seen("codex-b", ts=time.time() - 600)
+ resp = client.get("/agents/codex-b/presence")
+ data = resp.json()
+ assert data["status"] == "idle", f"expected idle, got {data['status']}"
+
+ def test_agent_presence_server_stamped_offline(self):
+ """Non-heartbeat agent stamped 1hr ago should be offline."""
+ client, registry = self._make_client_with_registry()
+ client.post("/agents", json={"name": "codex-c", "model": "opus"})
+ registry.stamp_last_seen("codex-c", ts=time.time() - 3600)
+ resp = client.get("/agents/codex-c/presence")
+ data = resp.json()
+ assert data["status"] == "offline", f"expected offline, got {data['status']}"
+
+ def test_agent_presence_no_stamp_no_heartbeat_is_unknown(self):
+ """Preserve existing behavior: no server stamp and no heartbeat → unknown."""
+ client = self._make_client()
+ client.post("/agents", json={"name": "ghost", "model": "sonnet"})
+ resp = client.get("/agents/ghost/presence")
+ data = resp.json()
+ assert data["status"] == "unknown"
+
+ def test_agent_presence_heartbeat_wins_when_fresher(self):
+ """If heartbeat is fresher than server stamp, heartbeat status logic applies."""
+ client, registry = self._make_client_with_registry()
+ client.post("/agents", json={"name": "cc-agent", "model": "sonnet"})
+ # Old server stamp
+ registry.stamp_last_seen("cc-agent", ts=time.time() - 3600)
+ # Fresh heartbeat — should override server stamp
+ registry.record_heartbeat("cc-agent", status="alive")
+ resp = client.get("/agents/cc-agent/presence")
+ data = resp.json()
+ assert data["status"] == "online"
+
def test_agent_directives_crud(self):
client = self._make_client()
client.post("/agents", json={"name": "alice", "model": "sonnet"})
@@ -2961,3 +3022,248 @@ def test_resolver_agent_explicit_fields_override_refs(self):
db=db,
)
assert (url, key, model) == ("https://api.deepseek.com/anthropic", "", "deepseek-chat")
+
+
+# ── Google OAuth CSRF state validation (#287) ────────────────────
+
+
+class TestGoogleOAuthStateValidation:
+ """Regression for #287: the legacy /calendar/google/callback endpoint must
+ validate a previously-issued state nonce before exchanging the code."""
+
+ def _make_app(self):
+ from pinky_daemon.api import create_api
+ fd, path = tempfile.mkstemp(suffix=".db")
+ os.close(fd)
+ return create_api(max_sessions=10, default_working_dir="/tmp", db_path=path)
+
+ def _seed_credentials(self, app):
+ """Seed client credentials so the callback can't early-return on them."""
+ agents = app.state.agents
+ # TokenStore uses these exact setting keys (see pinky_calendar.store).
+ agents.set_setting("GOOGLE_CALENDAR_CLIENT_ID", "test-client-id")
+ agents.set_setting("GOOGLE_CALENDAR_CLIENT_SECRET", "test-client-secret")
+
+ def test_callback_rejects_missing_state(self):
+ app = self._make_app()
+ self._seed_credentials(app)
+ with TestClient(app) as client:
+ resp = client.get(
+ "/calendar/google/callback",
+ params={"code": "auth-code", "state": ""},
+ follow_redirects=False,
+ )
+ assert resp.status_code == 400
+ assert "missing state" in resp.text.lower()
+
+ def test_callback_rejects_unknown_state(self):
+ app = self._make_app()
+ self._seed_credentials(app)
+ with TestClient(app) as client:
+ resp = client.get(
+ "/calendar/google/callback",
+ params={"code": "auth-code", "state": "attacker-forged-nonce"},
+ follow_redirects=False,
+ )
+ assert resp.status_code == 400
+ assert "unknown or replayed" in resp.text.lower()
+
+ def test_callback_rejects_expired_state(self):
+ from datetime import datetime, timedelta, timezone
+ app = self._make_app()
+ self._seed_credentials(app)
+ # Manually insert a state key with an issued timestamp in the distant past.
+ stale = (datetime.now(tz=timezone.utc) - timedelta(hours=1)).isoformat()
+ app.state.agents.set_setting("GOOGLE_OAUTH_STATE_stale-nonce", stale)
+ with TestClient(app) as client:
+ resp = client.get(
+ "/calendar/google/callback",
+ params={"code": "auth-code", "state": "stale-nonce"},
+ follow_redirects=False,
+ )
+ assert resp.status_code == 400
+ assert "expired" in resp.text.lower()
+ # Expired state must be purged so it can't be retried.
+ assert app.state.agents.get_setting("GOOGLE_OAUTH_STATE_stale-nonce") == ""
+
+ def test_callback_rejects_replayed_state(self):
+ """A state nonce must be single-use. After one consume, a second hit
+ with the same nonce must be treated as unknown."""
+ from datetime import datetime, timezone
+ from unittest.mock import patch as _patch
+
+ app = self._make_app()
+ self._seed_credentials(app)
+ fresh = datetime.now(tz=timezone.utc).isoformat()
+ app.state.agents.set_setting("GOOGLE_OAUTH_STATE_one-shot", fresh)
+
+ # First use consumes the nonce. We don't care about the token-exchange
+ # outcome here (which will fail because we're not mocking Google), we
+ # only need to confirm the state was accepted + deleted.
+ fake_tokens = {"access_token": "a", "refresh_token": "r", "expiry": None}
+ with _patch("pinky_calendar.oauth.exchange_code", return_value=fake_tokens):
+ with TestClient(app) as client:
+ first = client.get(
+ "/calendar/google/callback",
+ params={"code": "auth-code", "state": "one-shot"},
+ follow_redirects=False,
+ )
+ assert first.status_code == 200 # happy path
+ assert app.state.agents.get_setting("GOOGLE_OAUTH_STATE_one-shot") == ""
+ # Replay with the same nonce must now be rejected.
+ replay = client.get(
+ "/calendar/google/callback",
+ params={"code": "auth-code", "state": "one-shot"},
+ follow_redirects=False,
+ )
+ assert replay.status_code == 400
+ assert "unknown or replayed" in replay.text.lower()
+
+ def test_callback_deletes_state_even_on_exchange_failure(self):
+ """If exchange_code raises, the state nonce must still have been
+ consumed — otherwise an attacker could race a valid flow."""
+ from datetime import datetime, timezone
+ from unittest.mock import patch as _patch
+
+ app = self._make_app()
+ self._seed_credentials(app)
+ fresh = datetime.now(tz=timezone.utc).isoformat()
+ app.state.agents.set_setting("GOOGLE_OAUTH_STATE_single-use", fresh)
+
+ with _patch(
+ "pinky_calendar.oauth.exchange_code",
+ side_effect=RuntimeError("invalid code"),
+ ):
+ with TestClient(app) as client:
+ resp = client.get(
+ "/calendar/google/callback",
+ params={"code": "bad-code", "state": "single-use"},
+ follow_redirects=False,
+ )
+ assert resp.status_code == 400
+ assert "oauth error" in resp.text.lower()
+ assert app.state.agents.get_setting("GOOGLE_OAUTH_STATE_single-use") == ""
+
+ def test_direct_auth_url_persists_state(self):
+ """The direct-auth-url endpoint must persist a single-use state nonce
+ that the callback can later validate against."""
+ from unittest.mock import patch as _patch
+
+ app = self._make_app()
+ self._seed_credentials(app)
+ with _patch(
+ "pinky_calendar.oauth.get_auth_url",
+ return_value=("https://accounts.google.com/o/oauth2/auth?state=xyz", "xyz"),
+ ):
+ with TestClient(app) as client:
+ resp = client.get("/calendar/google/direct-auth-url")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["state"] == "xyz"
+ assert "accounts.google.com" in body["auth_url"]
+ # State key must be present and non-empty (timestamp).
+ stored = app.state.agents.get_setting("GOOGLE_OAUTH_STATE_xyz")
+ assert stored != ""
+
+ def test_direct_auth_url_requires_credentials(self):
+ """Without stored client credentials, direct-auth must 400 — there's
+ nothing to build a direct-Google auth URL for."""
+ app = self._make_app()
+ # Intentionally skip _seed_credentials().
+ with TestClient(app) as client:
+ resp = client.get("/calendar/google/direct-auth-url")
+ assert resp.status_code == 400
+
+ def test_consume_fails_closed_when_delete_returns_false(self):
+ """Regression for Murzik's review: if delete_setting() returns False
+ (because a concurrent consumer already deleted the row), we must NOT
+ proceed to token exchange. Treat the loser of the race as replayed."""
+ from datetime import datetime, timezone
+ from unittest.mock import patch as _patch
+
+ app = self._make_app()
+ self._seed_credentials(app)
+ fresh = datetime.now(tz=timezone.utc).isoformat()
+ app.state.agents.set_setting("GOOGLE_OAUTH_STATE_race-nonce", fresh)
+
+ # Simulate the race: get_setting sees the value, but by the time we
+ # try to delete it, a concurrent consumer has beaten us to it.
+ real_delete = app.state.agents.delete_setting
+
+ def fake_delete(key):
+ if key == "GOOGLE_OAUTH_STATE_race-nonce":
+ return False # Another consumer won the race.
+ return real_delete(key)
+
+ exchange_called = {"count": 0}
+
+ def fake_exchange(*args, **kwargs):
+ exchange_called["count"] += 1
+ return {"access_token": "a", "refresh_token": "r", "expiry": None}
+
+ with _patch.object(app.state.agents, "delete_setting", fake_delete), \
+ _patch("pinky_calendar.oauth.exchange_code", fake_exchange):
+ with TestClient(app) as client:
+ resp = client.get(
+ "/calendar/google/callback",
+ params={"code": "auth-code", "state": "race-nonce"},
+ follow_redirects=False,
+ )
+ assert resp.status_code == 400
+ assert "unknown or replayed" in resp.text.lower()
+ # Critical: exchange must not have been invoked.
+ assert exchange_called["count"] == 0
+
+ def test_consume_fails_closed_when_delete_raises(self):
+ """Regression for Murzik's review: if delete_setting() raises (DB
+ error, disk full, etc.), fail closed — don't exchange the code."""
+ from datetime import datetime, timezone
+ from unittest.mock import patch as _patch
+
+ app = self._make_app()
+ self._seed_credentials(app)
+ fresh = datetime.now(tz=timezone.utc).isoformat()
+ app.state.agents.set_setting("GOOGLE_OAUTH_STATE_boom", fresh)
+
+ def raising_delete(key):
+ raise sqlite3.OperationalError("database is locked")
+
+ exchange_called = {"count": 0}
+
+ def fake_exchange(*args, **kwargs):
+ exchange_called["count"] += 1
+ return {"access_token": "a", "refresh_token": "r", "expiry": None}
+
+ with _patch.object(app.state.agents, "delete_setting", raising_delete), \
+ _patch("pinky_calendar.oauth.exchange_code", fake_exchange):
+ with TestClient(app) as client:
+ resp = client.get(
+ "/calendar/google/callback",
+ params={"code": "auth-code", "state": "boom"},
+ follow_redirects=False,
+ )
+ assert resp.status_code == 400
+ assert "could not consume state" in resp.text.lower()
+ assert exchange_called["count"] == 0
+
+ def test_callback_rejects_naive_timestamp_state(self):
+ """Regression for Murzik's review: datetime.fromisoformat() can return
+ a naive datetime for valid ISO strings without a tz suffix, and
+ subtracting naive from aware raises TypeError → 500. Reject naive
+ records as corrupt so the callback still returns a clean 400."""
+ app = self._make_app()
+ self._seed_credentials(app)
+ # No tz suffix — fromisoformat() will parse this as naive.
+ app.state.agents.set_setting(
+ "GOOGLE_OAUTH_STATE_naive-nonce", "2026-04-21T10:00:00",
+ )
+ with TestClient(app) as client:
+ resp = client.get(
+ "/calendar/google/callback",
+ params={"code": "auth-code", "state": "naive-nonce"},
+ follow_redirects=False,
+ )
+ assert resp.status_code == 400
+ assert "corrupt state" in resp.text.lower()
+ # Still purged, even though it was malformed — can't be retried.
+ assert app.state.agents.get_setting("GOOGLE_OAUTH_STATE_naive-nonce") == ""
diff --git a/tests/test_broker.py b/tests/test_broker.py
index 9f05a8ba..910ce97c 100644
--- a/tests/test_broker.py
+++ b/tests/test_broker.py
@@ -94,6 +94,38 @@ async def test_route_response_skips_plain_text_when_outreach_used(self):
finally:
tmpdir.cleanup()
+ @pytest.mark.asyncio
+ async def test_inject_agent_message_stamps_last_seen_on_success(self):
+ tmpdir, registry, broker, _, _ = self._make_broker()
+ try:
+ class _FakeStreaming:
+ is_connected = True
+ sent: list[str] = []
+
+ async def send(self, prompt: str) -> None:
+ _FakeStreaming.sent.append(prompt)
+
+ broker.register_streaming("barsik", _FakeStreaming(), label="main")
+ assert registry.get("barsik").last_seen_at == 0.0
+
+ ok = await broker.inject_agent_message("pushok", "barsik", "hi")
+ assert ok is True
+ assert registry.get("barsik").last_seen_at > 0.0
+ assert _FakeStreaming.sent # delivery happened
+ finally:
+ tmpdir.cleanup()
+
+ @pytest.mark.asyncio
+ async def test_inject_agent_message_does_not_stamp_when_not_connected(self):
+ tmpdir, registry, broker, _, _ = self._make_broker()
+ try:
+ # No streaming session registered — inject should fail without stamping.
+ ok = await broker.inject_agent_message("pushok", "barsik", "hi")
+ assert ok is False
+ assert registry.get("barsik").last_seen_at == 0.0
+ finally:
+ tmpdir.cleanup()
+
def test_remember_message_context_tracks_voice_and_reply_metadata(self):
tmpdir, _, broker, _, _ = self._make_broker()
try:
diff --git a/tests/test_calendar_server.py b/tests/test_calendar_server.py
new file mode 100644
index 00000000..bf65bfe8
--- /dev/null
+++ b/tests/test_calendar_server.py
@@ -0,0 +1,26 @@
+"""Tests for pinky_calendar MCP tools."""
+
+from __future__ import annotations
+
+import json
+
+from pinky_calendar.server import create_server
+
+
+def _tools(srv):
+ return {t.name: t.fn for t in srv._tool_manager.list_tools()}
+
+
+class TestCalendarDateParsing:
+ def test_get_events_invalid_start_date_returns_tool_error(self):
+ srv = create_server(
+ caldav_url="https://calendar.example.test",
+ caldav_username="user",
+ caldav_password="pass",
+ )
+
+ raw = _tools(srv)["get_events"](start_date="not-a-date")
+ result = json.loads(raw)
+
+ assert "error" in result
+ assert "Invalid date" in result["error"]
diff --git a/tests/test_codex_session.py b/tests/test_codex_session.py
index d75680f8..d89e45a6 100644
--- a/tests/test_codex_session.py
+++ b/tests/test_codex_session.py
@@ -164,6 +164,66 @@ async def test_error_item(self):
r = await self._parse_events(events)
assert "bad model" in r.errors
+ @pytest.mark.asyncio
+ async def test_error_event_stamps_last_seen(self):
+ """Transport/runtime errors (event_type=error) should stamp last_seen —
+ it's a real liveness signal even when no turn.failed fires."""
+ stamps: list[str] = []
+
+ class _MockRegistry:
+ def stamp_last_seen(self, name: str, ts: float | None = None) -> None:
+ stamps.append(name)
+
+ config = StreamingSessionConfig(
+ agent_name="test-agent", working_dir="/tmp", provider_url="codex_cli",
+ )
+ session = CodexSession(config, registry=_MockRegistry())
+ result = CodexTurnResult()
+ await session._handle_event(
+ {"type": "error", "message": "transport closed"}, result,
+ )
+ assert stamps == ["test-agent"]
+ assert "transport closed" in result.errors[0]
+
+ @pytest.mark.asyncio
+ async def test_turn_completed_stamps_last_seen(self):
+ """Regression guard: turn.completed continues to stamp."""
+ stamps: list[str] = []
+
+ class _MockRegistry:
+ def stamp_last_seen(self, name: str, ts: float | None = None) -> None:
+ stamps.append(name)
+
+ config = StreamingSessionConfig(
+ agent_name="test-agent", working_dir="/tmp", provider_url="codex_cli",
+ )
+ session = CodexSession(config, registry=_MockRegistry())
+ result = CodexTurnResult()
+ await session._handle_event(
+ {"type": "turn.completed", "usage": {"input_tokens": 5, "output_tokens": 2}},
+ result,
+ )
+ assert stamps == ["test-agent"]
+
+ @pytest.mark.asyncio
+ async def test_turn_failed_stamps_last_seen(self):
+ """Regression guard: turn.failed continues to stamp."""
+ stamps: list[str] = []
+
+ class _MockRegistry:
+ def stamp_last_seen(self, name: str, ts: float | None = None) -> None:
+ stamps.append(name)
+
+ config = StreamingSessionConfig(
+ agent_name="test-agent", working_dir="/tmp", provider_url="codex_cli",
+ )
+ session = CodexSession(config, registry=_MockRegistry())
+ result = CodexTurnResult()
+ await session._handle_event(
+ {"type": "turn.failed", "error": {"message": "rate limited"}}, result,
+ )
+ assert stamps == ["test-agent"]
+
class TestCodexSessionSendDrop:
"""Verify send() drops messages when not connected."""
diff --git a/tests/test_discord.py b/tests/test_discord.py
index a9552e83..4de93eb2 100644
--- a/tests/test_discord.py
+++ b/tests/test_discord.py
@@ -4,6 +4,7 @@
from unittest.mock import MagicMock
+import httpx
import pytest
from pinky_outreach.discord import DiscordAdapter, DiscordError
@@ -83,6 +84,45 @@ def test_send_message_error(self):
assert exc.value.status_code == 403
adapter.close()
+ def test_send_file_success_uses_httpx_multipart_header(self, tmp_path):
+ """Regression: passing Content-Type=None makes httpx raise before sending."""
+ adapter = self._make_adapter()
+ file_path = tmp_path / "note.txt"
+ file_path.write_text("hello discord")
+ seen_requests = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen_requests.append(request)
+ body = request.read()
+ assert request.method == "POST"
+ assert request.url.path.endswith("/channels/99999/messages")
+ assert request.headers["content-type"].startswith("multipart/form-data; boundary=")
+ assert b'name="files[0]"' in body
+ assert b'filename="note.txt"' in body
+ assert b"hello discord" in body
+ return httpx.Response(
+ 200,
+ json={
+ "id": "file-msg-1",
+ "channel_id": "99999",
+ "content": "upload caption",
+ "timestamp": "2026-03-27T12:02:00+00:00",
+ },
+ )
+
+ adapter._client.close()
+ adapter._client = httpx.Client(
+ base_url=adapter.BASE_URL,
+ headers={"Authorization": "Bot fake-discord-token"},
+ transport=httpx.MockTransport(handler),
+ )
+
+ msg = adapter.send_file("99999", str(file_path), content="upload caption")
+ assert msg.message_id == "file-msg-1"
+ assert msg.content == "upload caption"
+ assert len(seen_requests) == 1
+ adapter.close()
+
def test_get_messages_empty(self):
adapter = self._make_adapter()
mock_response = MagicMock()
diff --git a/tests/test_memory_server.py b/tests/test_memory_server.py
index 73398def..89df702e 100644
--- a/tests/test_memory_server.py
+++ b/tests/test_memory_server.py
@@ -188,6 +188,94 @@ def test_reflect_entities_lowercased(self, srv, store):
assert "brad" in ref.entities
assert "oleg" in ref.entities
+ def test_reflect_reports_embedded_false_with_noop(self, srv):
+ """Regression for #291: response must surface whether an embedding was generated."""
+ result = json.loads(_tools(srv)["reflect"](content="fact without embedding", type="fact"))
+ assert result["stored"] is True
+ assert result["embedded"] is False
+
+ def test_reflect_reports_embedded_true_with_real_embedder(self, store, capsys):
+ """When a real embedder returns a vector, response reflects embedded=True."""
+ class FakeRealEmbedder:
+ dimensions = 8
+
+ def embed(self, text: str) -> list[float]:
+ return [0.1] * 8
+
+ srv = create_server(store, FakeRealEmbedder())
+ result = json.loads(_tools(srv)["reflect"](content="embedded fact", type="fact"))
+ assert result["stored"] is True
+ assert result["embedded"] is True
+
+ def test_reflect_logs_when_real_embedder_returns_empty(self, store, capsys):
+ """Regression for #291: real embedder returning [] must log degraded mode."""
+ class DegradedRealEmbedder:
+ dimensions = 8
+
+ def embed(self, text: str) -> list[float]:
+ return []
+
+ srv = create_server(store, DegradedRealEmbedder())
+ result = json.loads(_tools(srv)["reflect"](content="degraded", type="fact"))
+ assert result["stored"] is True
+ assert result["embedded"] is False
+ captured = capsys.readouterr()
+ assert "degraded" in captured.err.lower()
+
+ def test_reflect_survives_embedder_exception(self, store, capsys):
+ """Regression for #291: embedder raising must not abort reflect(); log instead."""
+ class RaisingEmbedder:
+ dimensions = 8
+
+ def embed(self, text: str) -> list[float]:
+ raise RuntimeError("network error")
+
+ srv = create_server(store, RaisingEmbedder())
+ result = json.loads(_tools(srv)["reflect"](content="survives", type="fact"))
+ assert result["stored"] is True
+ assert result["embedded"] is False
+ captured = capsys.readouterr()
+ assert "runtimeerror" in captured.err.lower()
+ assert "network error" in captured.err.lower()
+
+ def test_reflect_treats_zero_norm_vector_as_degraded(self, store, capsys):
+ """Regression for #291 (Murzik review): real embedder returning [0.0, ...]
+ must be treated as degraded, not stored as a usable embedding.
+
+ Without this check, reflect() would report embedded=True and store an
+ all-zero vector that search_by_embedding silently skips (zero-norm row
+ guard in _search_by_numpy), reproducing the original silent-degrade bug.
+ """
+ class ZeroVectorEmbedder:
+ dimensions = 8
+
+ def embed(self, text: str) -> list[float]:
+ return [0.0] * 8
+
+ srv = create_server(store, ZeroVectorEmbedder())
+ result = json.loads(_tools(srv)["reflect"](content="all zeros", type="fact"))
+ assert result["stored"] is True
+ assert result["embedded"] is False
+ ref = store.get(result["id"])
+ assert ref.embedding == []
+ captured = capsys.readouterr()
+ assert "degenerate" in captured.err.lower()
+
+ def test_reflect_treats_nonfinite_vector_as_degraded(self, store, capsys):
+ """Non-finite values (NaN/inf) in an embedding are also degraded."""
+ class NaNEmbedder:
+ dimensions = 8
+
+ def embed(self, text: str) -> list[float]:
+ return [float("nan")] + [0.1] * 7
+
+ srv = create_server(store, NaNEmbedder())
+ result = json.loads(_tools(srv)["reflect"](content="nan inside", type="fact"))
+ assert result["stored"] is True
+ assert result["embedded"] is False
+ captured = capsys.readouterr()
+ assert "degenerate" in captured.err.lower()
+
# ── recall tool ────────────────────────────────────────────────────────────────
diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py
index 293811f2..6587ec94 100644
--- a/tests/test_memory_store.py
+++ b/tests/test_memory_store.py
@@ -4,7 +4,9 @@
from datetime import datetime, timedelta, timezone
from pathlib import Path
-from pinky_memory.store import ReflectionStore
+import pytest
+
+from pinky_memory.store import InvalidQueryEmbeddingError, ReflectionStore
from pinky_memory.types import MemoryQueryFilters, Reflection, ReflectionType
# ── Helpers ────────────────────────────────────────────────────────────────────
@@ -284,6 +286,146 @@ def test_search_excludes_no_recall(self, tmp_path):
results = store.search_by_embedding(emb)
assert all(x.id != r.id for x in results)
+ def test_zero_norm_query_raises(self, tmp_path):
+ """A zero-norm query embedding must raise — not silently return [].
+
+ Regression for #285: zero-norm previously returned [] which could
+ not be distinguished from a legitimate 'no matches' result.
+ """
+ store = _store(tmp_path)
+ store.insert(_fact("some content", embedding=_emb()))
+ with pytest.raises(InvalidQueryEmbeddingError):
+ store.search_by_embedding([0.0] * 8)
+ with pytest.raises(InvalidQueryEmbeddingError):
+ store.search_by_embedding_scored([0.0] * 8)
+
+ def test_empty_query_embedding_raises(self, tmp_path):
+ """An empty query embedding must raise — regression for #285."""
+ store = _store(tmp_path)
+ with pytest.raises(InvalidQueryEmbeddingError):
+ store.search_by_embedding_scored([])
+
+ def test_vec_fallback_logs_and_counts(self, tmp_path, caplog, monkeypatch):
+ """Regression for #290: sqlite-vec failures must log + bump fallback counter.
+
+ Previously the fallback to numpy was silent, so a broken vec extension
+ meant slower/differently-ordered search with no signal.
+ """
+ import logging as _logging
+
+ store = _store(tmp_path)
+ emb = _emb()
+ store.insert(_fact("a", embedding=emb))
+
+ if not store._vec_available:
+ # Make _search_by_vec reachable for this test even without sqlite-vec.
+ store._vec_available = True
+ store._vec_dimensions = len(emb)
+
+ class FailingVecConn:
+ """Wraps a real sqlite3.Connection; raises on reflections_vec queries only."""
+
+ def __init__(self, inner):
+ self._inner = inner
+
+ def execute(self, sql, *args, **kwargs):
+ if "reflections_vec" in sql:
+ raise RuntimeError("simulated vec failure")
+ return self._inner.execute(sql, *args, **kwargs)
+
+ def __getattr__(self, name):
+ return getattr(self._inner, name)
+
+ monkeypatch.setattr(store, "_conn", FailingVecConn(store._conn))
+
+ before = store._vec_fallback_count
+ with caplog.at_level(_logging.WARNING, logger="pinky_memory.store"):
+ results = store.search_by_embedding(emb)
+ # Numpy fallback still returns results
+ assert len(results) >= 1
+ # Counter bumped
+ assert store._vec_fallback_count == before + 1
+ # Warning logged with exception info
+ messages = " ".join(r.getMessage() for r in caplog.records)
+ assert "sqlite-vec" in messages
+ assert "simulated vec failure" in messages
+ # Introspect exposes the counter
+ stats = store.introspect()
+ assert stats["vec_fallback_count"] == before + 1
+
+ def test_vec_fallback_preserves_type_exclude(self, tmp_path, monkeypatch):
+ """Regression for #290 (Murzik review): fallback must preserve
+ `type_exclude` — previously it was dropped when vec failed, so
+ callers that excluded a type could get excluded rows back.
+ """
+ store = _store(tmp_path)
+ emb = _emb()
+ # Insert one memory of each type so the test can detect leakage.
+ insight = store.insert(Reflection(
+ type=ReflectionType.insight, content="should be excluded", embedding=emb,
+ ))
+ fact = store.insert(Reflection(
+ type=ReflectionType.fact, content="should remain", embedding=emb,
+ ))
+
+ if not store._vec_available:
+ store._vec_available = True
+ store._vec_dimensions = len(emb)
+
+ class FailingVecConn:
+ def __init__(self, inner):
+ self._inner = inner
+
+ def execute(self, sql, *args, **kwargs):
+ if "reflections_vec" in sql:
+ raise RuntimeError("simulated vec failure")
+ return self._inner.execute(sql, *args, **kwargs)
+
+ def __getattr__(self, name):
+ return getattr(self._inner, name)
+
+ monkeypatch.setattr(store, "_conn", FailingVecConn(store._conn))
+
+ scored = store.search_by_embedding_scored(
+ emb, limit=10, type_exclude=[ReflectionType.insight],
+ )
+ returned_ids = {ref.id for _, ref in scored}
+ assert insight.id not in returned_ids, (
+ "fallback leaked a type that was in type_exclude"
+ )
+ assert fact.id in returned_ids
+
+ def test_detect_dims_skips_corrupt_embedding_json(self, tmp_path, caplog):
+ """Regression for #289: a single corrupt embedding row must not abort
+ dimension detection / startup."""
+ import logging as _logging
+
+ store = _store(tmp_path)
+ good_emb = _emb(8, 0.2)
+ good = store.insert(_fact("good row", embedding=good_emb))
+ # Corrupt the embedding of one other row directly via SQL so the
+ # created_at ordering still lets the detector walk past it.
+ bad = store.insert(_fact("bad row", embedding=_emb(8, 0.3)))
+ store._conn.execute(
+ "UPDATE reflections SET embedding = ? WHERE id = ?",
+ ("{not json", bad.id),
+ )
+ # Make the corrupt row newer so DESC ordering hits it first.
+ store._conn.execute(
+ "UPDATE reflections SET created_at = '2099-01-01T00:00:00+00:00' "
+ "WHERE id = ?",
+ (bad.id,),
+ )
+ store._conn.commit()
+ with caplog.at_level(_logging.WARNING, logger="pinky_memory.store"):
+ dims = store._detect_embedding_dimensions()
+ assert dims == 8 # recovered from the good row
+ messages = " ".join(r.getMessage() for r in caplog.records)
+ assert bad.id in messages
+ assert "malformed" in messages
+ # Good row is untouched
+ assert store.get(good.id) is not None
+
# ── Near-Duplicate Detection ───────────────────────────────────────────────────
diff --git a/tests/test_outreach.py b/tests/test_outreach.py
index 74ee73ce..768ce889 100644
--- a/tests/test_outreach.py
+++ b/tests/test_outreach.py
@@ -148,6 +148,32 @@ def test_send_message_error(self):
assert "chat not found" in str(exc.value)
adapter.close()
+ @pytest.mark.parametrize(
+ "method_name",
+ ["send_photo", "send_document", "send_animation"],
+ )
+ def test_media_sends_omit_none_reply_to_message_id(self, method_name, tmp_path):
+ adapter = self._make_adapter()
+ media_path = tmp_path / "media.bin"
+ media_path.write_bytes(b"media")
+ mock_response = MagicMock()
+ mock_response.json.return_value = {
+ "ok": True,
+ "result": {
+ "message_id": 42,
+ "chat": {"id": 12345, "type": "private"},
+ "date": 1711584000,
+ },
+ }
+ adapter._client.post = MagicMock(return_value=mock_response)
+
+ getattr(adapter, method_name)("12345", str(media_path), caption="hi")
+
+ data = adapter._client.post.call_args.kwargs["data"]
+ assert "reply_to_message_id" not in data
+ assert data["chat_id"] == "12345"
+ adapter.close()
+
def test_get_updates_empty(self):
adapter = self._make_adapter()
mock_response = MagicMock()
diff --git a/tests/test_pinky_self_tools.py b/tests/test_pinky_self_tools.py
index f04585a9..1d6cf86a 100644
--- a/tests/test_pinky_self_tools.py
+++ b/tests/test_pinky_self_tools.py
@@ -1902,6 +1902,47 @@ def test_error_returns_fallback(self, srv):
}
+class TestKbUrlEncoding:
+ def _capture_urls(self, response: dict):
+ seen: list[str] = []
+
+ def _urlopen(req, timeout=30):
+ seen.append(req.full_url if hasattr(req, "full_url") else str(req))
+ body = json.dumps(response).encode()
+ resp = MagicMock()
+ resp.read.return_value = body
+ resp.__enter__ = lambda s: s
+ resp.__exit__ = MagicMock(return_value=False)
+ return resp
+
+ return seen, patch("urllib.request.urlopen", side_effect=_urlopen)
+
+ def test_kb_search_encodes_query_params(self, srv):
+ seen, patched = self._capture_urls({"results": []})
+ with patched:
+ _tools(srv)["kb_search"](query="alpha & scope=raw", scope="wiki")
+
+ assert "q=alpha+%26+scope%3Draw" in seen[0]
+ assert "&scope=wiki" in seen[0]
+ assert "alpha & scope=raw" not in seen[0]
+
+ def test_kb_wiki_slug_encodes_path_specials(self, srv):
+ seen, patched = self._capture_urls({"content": "ok"})
+ with patched:
+ result = _tools(srv)["kb_get_wiki"](topic="topics/foo bar?draft=1")
+
+ assert result == "ok"
+ assert "/kb/wiki/topics/foo%20bar%3Fdraft%3D1?include_content=true" in seen[0]
+
+ def test_kb_raw_source_id_encodes_path_segment(self, srv):
+ seen, patched = self._capture_urls({})
+ with patched:
+ result = _tools(srv)["kb_delete_raw"](source_id="raw/odd id")
+
+ assert "deleted" in result.lower()
+ assert "/kb/raw/raw%2Fodd%20id" in seen[0]
+
+
class TestToolGates:
def test_core_only_has_23_tools(self):
"""No gates → only core tools registered."""
diff --git a/tests/test_voice_engine.py b/tests/test_voice_engine.py
new file mode 100644
index 00000000..c4f6e099
--- /dev/null
+++ b/tests/test_voice_engine.py
@@ -0,0 +1,94 @@
+"""Tests for pinky_daemon.voice_engine helpers.
+
+Focused on regression coverage for #286 — finalize_call() getting handed
+a None session under WS-disconnect races.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import MagicMock
+
+import pytest
+
+from pinky_daemon.voice_engine import finalize_call
+
+
+class TestFinalizeCallNoneGuard:
+ """Regression for #286: under session-expiry races,
+ `voice_store.get_session()` can return None. Previously this raised
+ `AttributeError: 'NoneType' object has no attribute 'call_sid'` because
+ `finalize_call()` went straight to `session.call_sid` in the first log
+ line. It now no-ops + logs instead."""
+
+ def test_finalize_call_with_none_session_noops(self):
+ voice_store = MagicMock()
+ agents = MagicMock()
+ broker_send = MagicMock()
+
+ # Must not raise AttributeError.
+ asyncio.run(
+ finalize_call(
+ None,
+ voice_store,
+ agents,
+ broker_send=broker_send,
+ api_key="test",
+ )
+ )
+
+ # No voice_store / agents / broker side effects on the None path.
+ voice_store.get_call_request.assert_not_called()
+ voice_store.get_events.assert_not_called()
+ voice_store.save_artifact.assert_not_called()
+ broker_send.assert_not_called()
+
+ def test_finalize_call_with_real_session_proceeds(self):
+ """Sanity check: a real session is not short-circuited by the guard.
+
+ Uses an empty events list so finalize returns after transcript check
+ (no Opus/network dependency)."""
+ session = MagicMock()
+ session.call_sid = "CA123"
+ session.id = "session-abc"
+ session.call_request_id = None
+
+ voice_store = MagicMock()
+ voice_store.get_events.return_value = []
+ agents = MagicMock()
+
+ asyncio.run(
+ finalize_call(
+ session,
+ voice_store,
+ agents,
+ broker_send=None,
+ api_key="test",
+ )
+ )
+
+ # We got past the None guard and the initial log line — evidenced by
+ # get_events being called (which is the step right after the goal
+ # lookup).
+ voice_store.get_events.assert_called_once_with("session-abc")
+ # Empty transcript → save_artifact should be skipped.
+ voice_store.save_artifact.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ "bad_value",
+ [None],
+ ids=["none"],
+)
+def test_finalize_call_parametrized_nones(bad_value):
+ """Keep the bad-input set explicit so regressions show up in the
+ parametrize ID."""
+ asyncio.run(
+ finalize_call(
+ bad_value,
+ MagicMock(),
+ MagicMock(),
+ broker_send=None,
+ api_key="",
+ )
+ )
diff --git a/tests/test_whatsapp.py b/tests/test_whatsapp.py
index f8892ec6..c656edeb 100644
--- a/tests/test_whatsapp.py
+++ b/tests/test_whatsapp.py
@@ -232,6 +232,7 @@ def test_two_step_download(self, tmp_path):
# Step 2: download the file
file_content = b"fake_image_bytes"
download_resp = MagicMock()
+ download_resp.status_code = 200
download_resp.content = file_content
def fake_request(method, path, **kwargs):
@@ -255,6 +256,25 @@ def test_missing_url_raises(self, tmp_path):
adapter.download_file("bad_id", dest_dir=str(tmp_path))
assert "No download URL" in exc_info.value.message
+ def test_download_http_error_raises_without_writing_file(self, tmp_path):
+ adapter = _wa()
+ media_info = {"url": "https://example.com/file.jpg", "mime_type": "image/jpeg"}
+ error_resp = MagicMock()
+ error_resp.status_code = 401
+ error_resp.json.return_value = {
+ "error": {"message": "Expired token", "code": 190}
+ }
+ error_resp.content = b"not an image"
+
+ with patch.object(adapter, "_request", return_value=media_info), \
+ patch.object(adapter._client, "get", return_value=error_resp):
+ with pytest.raises(WhatsAppError) as exc_info:
+ adapter.download_file("media_id_123", dest_dir=str(tmp_path))
+
+ assert exc_info.value.error_code == 190
+ assert "Expired token" in exc_info.value.message
+ assert not (tmp_path / "wa_media_id_123.jpeg").exists()
+
# ── get_me ─────────────────────────────────────────────────────────────────────