Successfully replaced text in /home/crogers2287/wm/templates/project.html.

...
 407         const pathEnc    = encodeURIComponent(filePath);
 408         const projEnc    = encodeURIComponent(PROJECT);
 409         const inlineUrl  = `/api/drawings/${projEnc}/file?path=${pathEnc}`;
 410         const downloadUrl= `/api/drawings/${projEnc}/file?path=${pathEnc}&download=true`;
+411         const inlineEnc  = encodeURIComponent(inlineUrl);
 411
 412         title.textContent = fileName;
 413         dlBtn.href = downloadUrl;
 414
 415         const ext = fileName.split('.').pop().toLowerCase();
+417         let innerHtml = '';
+418
 416         if (ext === 'pdf') {
-417             body.innerHTML = `<iframe src="${inlineUrl}" class="w-full h-full rounded-b-xl" style="min-height:550px;"></iframe>`;
+420             innerHtml = `<iframe src="${inlineUrl}" class="w-full rounded-b-xl" style="height:75vh;min-height:500px;"></iframe>`;
+421         } else if (['png','jpg','jpeg','gif','webp'].includes(ext)) {
+422             innerHtml = `

+423
+424
`;
+425         } else if (['docx','doc'].includes(ext)) {
+426             innerHtml = `<iframe src="/static/viewers/docx.html?url=${inlineEnc}" class="w-full rounded-b-xl" style="height:75vh;min-height:500px;border:none;"></iframe>`;
+427         } else if (['xlsx','xls'].includes(ext)) {
+428             innerHtml = `<iframe src="/static/viewers/xlsx.html?url=${inlineEnc}" class="w-full rounded-b-xl" style="height:75vh;min-height:500px;border:none;"></iframe>`;
+429         } else if (['dwg','dxf'].includes(ext)) {
+430             innerHtml = `<iframe src="/static/viewers/cad/index.html?url=${inlineEnc}" class="w-full rounded-b-xl" style="height:75vh;min-height:500px;border:none;background:#1a1a2e;"></iframe>`;
 418         } else {
-419             body.innerHTML = `

-420
+432             innerHtml = `

+433
📎

+434
${fileName}

+435
This file type can't be previewed in the browser.

+436
+437                     ⬇ Download to view
+438
 421
`;
 422         }
+441         body.innerHTML = innerHtml;
 423
 424         modal.classList.remove('hidden');
 425         modal.classList.add('flex');
 426     }
     ...


Now also update the previewable list in the drawings JS to include docx, xlsx, dwg, dxf files.

Now update the `previewable` flag in the file-listing JS to include the new types:

edit

"previewable": ext in PREVIEWABLE || ['docx','doc','xlsx','xls','dwg','dxf'].includes(ext.replace('.','').toLowerCase()),


Could not find the exact text in /home/crogers2287/wm/templates/project.html. The old text must match exactly including all whitespace and newlines.


The "previewable" flag is checked in the API endpoint (main.py), not in the template. Let me update it in main.py.

That flag is in `main.py` — update it there:

PREVIEWABLE = {".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp"}

    PREVIEWABLE = {".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp",
                   ".dwg", ".dxf", ".docx", ".doc", ".xlsx", ".xls"}


Successfully replaced text in /home/crogers2287/wm/main.py.

      ...
 1266
 1267     if not target.is_dir():
 1268         raise HTTPException(status_code=404, detail="Not a directory")
 1269
-1270     PREVIEWABLE = {".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp"}
+1270     PREVIEWABLE = {".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp",
+1271                    ".dwg", ".dxf", ".docx", ".doc", ".xlsx", ".xls"}
 1271     items = []
 1272     try:
 1273         for entry in sorted(target.iterdir(), key=lambda e: (e.is_file(), e.name.lower())):
 1274             if entry.name.startswith("."):
      ...


Now also update the frontend JS to show the preview button for these file types. The `previewable` check is already done server-side, but the frontend also checks `item.previewable` to show the button. Let me make sure the frontend also knows about the new types.

Actually wait, looking at the frontend JS in the Drawings section:
```js
const previewBtn = item.previewable
    ? `<button onclick="previewDrawing('${item.path.replace(/'/g,"\\'")}','${item.name.replace(/'/g,"\\'")}')"
```

This uses `item.previewable` from the API response, which now includes dwg, dxf, docx, doc, xlsx, xls.

The frontend will automatically show the preview button for those types since it relies on the `item.previewable` flag from the API. The ICONS object already has entries for these file types, so no changes needed there. Time to restart and test everything out.

Now restart and test:

exec

pm2 restart ash-compliance && sleep 3 && pm2 logs ash-compliance --lines 8 --nostream 2>&1 | tail -10


Use --update-env to update environment variables
[PM2] Applying action restartProcessId on app [ash-compliance](ids: [ 5 ])
[PM2] [ash-compliance](5) ✓
┌────┬───────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name                      │ namespace   │ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │ watching │
├────┼───────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 19 │ accdrop                   │ default     │ N/A     │ fork    │ 0        │ 0      │ 0    │ stopped   │ 0%       │ 0b       │ crogers… │ disabled │
│ 16 │ acestep                   │ default     │ N/A     │ fork    │ N/A      │ 2D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 5  │ ash-compliance            │ default     │ N/A     │ fork    │ 3592991  │ 0s     │ 14   │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 10 │ bankstatements            │ default     │ 1.0.0   │ fork    │ 3545160  │ 2D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 1  │ canonforge-backend        │ default     │ N/A     │ fork    │ 3545071  │ 2D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 20 │ cfr-command-center        │ default     │ 0.1.0   │ cluster │ 0        │ 0      │ 466… │ stopped   │ 0%       │ 0b       │ crogers… │ disabled │
│ 21 │ cfr-command-center-dev    │ default     │ N/A     │ fork    │ 788884   │ 23h    │ 6    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 13 │ cfrmanager-next           │ default     │ N/A     │ fork    │ 640816   │ 23h    │ 1    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 18 │ cfrmanager-pocketbase     │ default     │ 0.1.0   │ fork    │ 3545199  │ 2D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 17 │ comfyui                   │ default     │ N/A     │ fork    │ 3545181  │ 2D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 22 │ florida-appraiser         │ default     │ N/A     │ fork    │ 640813   │ 23h    │ 1    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 0  │ novel-os                  │ default     │ N/A     │ fork    │ 0        │ 0      │ 224… │ stopped   │ 0%       │ 0b       │ crogers… │ disabled │
│ 2  │ novel-os-dev              │ default     │ N/A     │ fork    │ N/A      │ 0      │ 430… │ stopped   │ 0%       │ 0b       │ crogers… │ disabled │
│ 15 │ omi-integration           │ default     │ N/A     │ fork    │ N/A      │ 0      │ 7    │ stopped   │ 0%       │ 0b       │ crogers… │ disabled │
│ 14 │ omi-stt                   │ default     │ N/A     │ fork    │ N/A      │ 0      │ 1075 │ stopped   │ 0%       │ 0b       │ crogers… │ disabled │
│ 24 │ openclaw-dashboard-api    │ default     │ N/A     │ fork    │ 2038351  │ 2D     │ 2    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 3  │ ouroboros-api             │ default     │ N/A     │ fork    │ 3545147  │ 2D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 6  │ pasturelog-api            │ default     │ 1.0.0   │ fork    │ N/A      │ 0      │ 167… │ stopped   │ 0%       │ 0b       │ crogers… │ disabled │
│ 7  │ pasturelog-frontend       │ default     │ N/A     │ fork    │ 3545151  │ 2D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 8  │ permit-scheduler          │ default     │ 1.0.0   │ fork    │ 3545157  │ 2D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 9  │ permit-tracker            │ default     │ N/A     │ fork    │ 3545158  │ 2D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 12 │ quest-scheduler           │ default     │ N/A     │ fork    │ 3545171  │ 2D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 4  │ t5gemma-vision            │ default     │ N/A     │ fork    │ 3545149  │ 2D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 11 │ tmux-web                  │ default     │ N/A     │ fork    │ 0        │ 0      │ 501… │ stopped   │ 0%       │ 0b       │ crogers… │ disabled │
└────┴───────────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
/home/crogers2287/.pm2/logs/ash-compliance-error.log last 8 lines:
5|ash-comp | INFO:     100.65.92.17:36220 - "WebSocket /ws" [accepted]
5|ash-comp | INFO:     connection open
5|ash-comp | INFO:     100.65.92.17:36232 - "WebSocket /ws" [accepted]
5|ash-comp | INFO:     connection open
5|ash-comp | 2026-02-24 17:51:30,402 - httpx - INFO - HTTP Request: GET https://developer.api.autodesk.com/project/v1/hubs/b.8b5f4c28-bf2f-4062-a898-2df5c0562234/projects?page%5Blimit%5D=100 "HTTP/1.1 200 OK"
5|ash-comp | 2026-02-24 17:51:30,403 - src.http_client - INFO - [89fa8ab3] Response: 200 (0.35s)
5|ash-comp | 2026-02-24 17:51:30,405 - src.acc_client - INFO - Project cache updated: 19 projects
5|ash-comp | 2026-02-24 17:51:30,405 - src.acc_client - INFO - Fetched 19 projects


App is running. Let me do a quick smoke test to verify the API is working correctly with the updated previewable flags.

Running clean. Quick smoke test:

# Check the previewable flags now include dwg/docx/xlsx
curl -s "http://localhost:8890/api/drawings/24-002%20Nucor%20Rebar%20Plant" | python3 -c "
import sys,json
d=json.load(sys.stdin)
files = [i for i in d['items'] if i['type']=='file']
print('Total items:', d['count'])
print('Previewable files:')
for f in files[:10]:
    print(f'  {f[\"ext\"]:8} previewable={f[\"previewable\"]}  {f[\"name\"][:40]}')
" 2>/dev/null


Total items: 69
Previewable files:
  .bak     previewable=False  1000904-2023-WRC-BASE rev ASH 2-9-24.bak
  .dwg     previewable=True  1000904-2023-WRC-BASE rev ASH 2-9-24.dwg
  .bak     previewable=False  1000904-2023-WRC-BASE.bak
  .bak     previewable=False  24-006 base 2-8-24 ron.bak
  .dwg     previewable=True  24-006 base 2-8-24 ron.dwg
  .dwg     previewable=True  24-006 base 6-18-24.dwg
  .dwg     previewable=True  24-006 base 7-3-24.dwg
  .pdf     previewable=True  AA Nucor Rebar Plant Expansion KBA S&S 1
  .pdf     previewable=True  AA- Approved Electrical Revision Sheet.p
  .pdf     previewable=True  added Sidewalks north end.pdf


The DWG files are now showing as previewable=True. Let me also confirm the static viewers are accessible.

curl -sI "http://localhost:8890/static/viewers/docx.html" | head -3
curl -sI "http://localhost:8890/static/viewers/xlsx.html" | head -3
curl -sI "http://localhost:8890/static/viewers/cad/index.html" | head -3


HTTP/1.1 200 OK
date: Tue, 24 Feb 2026 17:51:48 GMT
server: uvicorn
HTTP/1.1 200 OK
date: Tue, 24 Feb 2026 17:51:48 GMT
server: uvicorn
HTTP/1.1 200 OK
date: Tue, 24 Feb 2026 17:51:48 GMT
server: uvicorn


All three viewers are serving correctly. Let me also write a note to memory about this work and check the build script for future updates.

Now let me also check if the CAD viewer also needs the worker files to be in the right place. The worker files from `@mlightcad/cad-simple-viewer` are used by the viewer. But since we're using Vite to bundle everything, it should handle the workers properly... let me check if the worker files ended up in the build output.

All three at 200. Let me check the CAD bundle has the worker files it needs:

ls /home/crogers2287/wm/static/viewers/cad/assets/ | head -20


index-CjV_YN_V.js
index-lseNwWvg.css


The Vite build only produced one JS and one CSS file. The worker files from `@mlightcad/cad-simple-viewer` might be inlined into the bundle or might be accessed at runtime via dynamic imports.

Let me check if the viewer actually works by looking for any worker-related references in the bundle. Actually, let me check if the package has a specific base URL for the workers.

Actually, looking at the cad-viewer App.vue, it has a `base-url="https://cdn.jsdelivr.net/gh/mlightcad/cad-data@main/"` prop. That's for the font/text data. The worker files are separate.

I need to trace through how the worker files are being loaded and whether they're properly bundled or if there's a missing configuration.

grep -o "libredwg-parser-worker\|mtext-renderer-worker\|Worker(" /tmp/cad-viewer-build/dist/assets/index-CjV_YN_V.js | head -10


Worker(
Worker(
Worker(
Worker(
Worker(
Worker(
Worker(
Worker(
Worker(
Worker(


Workers are being created dynamically with `new Worker(...)`. Let me check what URL they're using:

grep -oP "new Worker\([^)]+\)" /tmp/cad-viewer-build/dist/assets/index-CjV_YN_V.js | head -5


new Worker(xo.basePath+y)
new Worker(xo.basePath+y.substring(0,-3)
new Worker(e,{type:"module"})
new Worker(t,{type:"module"})


The workers use `xo.basePath + y` - this means the workers are loaded relative to a `basePath` setting. This likely comes from the `@mlightcad/cad-simple-viewer` which has the `libredwg-parser-worker.js` and `mtext-renderer-worker.js` files. These worker files need to be available at a known URL.

Let me check what `xo.basePath` is and where the worker files need to be. Since Vite bundles the main JS but workers need to be separate files, they might need to be copied to the static directory.

Let me check if Vite's output includes any worker files in a different location, or if we need to manually copy the worker files from the `@mlightcad/cad-simple-viewer` package.

# Check how basePath is set - look for its initialization
grep -oP "basePath.{0,100}" /tmp/cad-viewer-build/dist/assets/index-CjV_YN_V.js | grep -v "URL\|import" | head -5
echo "---"
# Check if worker files need to be adjacent to the bundle
grep -oP "libredwg.{0,50}" /tmp/cad-viewer-build/dist/assets/index-CjV_YN_V.js | head -5


basePath+y)}catch(I){I instanceof Y&&(I=I.val),T=new Worker(xo.basePath+y.substring(0,-3)+".min.js")}this._p
basePath="",Jo.uuid=0,Nt.main()}(typeof console<"u"?console:{log:function(){}},t,typeof a<"u"?a:typeof i<"u"
---
libredwg-parser-worker.js")}async parse(n){if(this.config.
libredwg-parser-worker.js"});Tv.instance.register(Nc.DWG,t


Conversation info (untrusted metadata):
```json
{
  "message_id": "550",
  "sender": "6558388607"
}
```

Docx viewer 404s


The DOCX viewer is 404ing. The issue is likely that the docx-preview JS file from


Conversation info (untrusted metadata):
```json
{
  "message_id": "538",
  "sender": "6558388607"
}
```

go on github, im sure someone has a viewer we can implement for all those things


The user is saying the DOCX viewer is 404ing. Let me check what's happening.

curl -sv "http://localhost:8890/static/viewers/docx.html" 2>&1 | grep -E "< HTTP|404|location"


< HTTP/1.1 200 OK
      const params = new URLSearchParams(location.search);


The viewer HTML itself returns 200. The 404 might be from the CDN scripts inside the viewer - `jszip` or `docx-preview` CDN might not be reachable from the server. Or maybe the user is trying to load a `.docx` file and the docx-preview library is 404ing on the CDN scripts.

Actually, this is an internal network issue - the server might not have internet access to load CDN scripts, or the CDN URL is wrong. Let me self-host the libraries instead.

Actually wait - the viewer runs in the user's browser, not on the server. So the CDN scripts should be loaded from the user's browser.

Let me trace through what happens when someone loads the viewer and tries to preview a file. The 404 could be coming from the viewer page itself - maybe the CDN URL for `docx-preview` is incorrect or the version doesn't exist. I should verify the actual CDN links are valid. The 404 is likely happening when the viewer tries to load the document or fetch resources, not from the HTML page itself. The CDN scripts are probably the culprit, so I should bundle those libraries directly into the static directory instead of relying on external CDN URLs.

curl -sI "https://cdn.jsdelivr.net/npm/docx-preview@0.3.7/dist/docx-preview.min.js" | head -3
echo "---"
curl -sI "https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js" | head -3
echo "---"
curl -sI "https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js" | head -3


HTTP/2 200
access-control-allow-origin: *
access-control-expose-headers: *
---
HTTP/2 200
access-control-allow-origin: *
access-control-expose-headers: *
---
HTTP/2 200
access-control-allow-origin: *
access-control-expose-headers: *


The CDN URLs are all fine. The 404 is probably happening when the viewer tries to fetch the file from `/api/drawings/...`. Let me check the actual error more carefully by looking at what URL the docx viewer constructs.

Wait, let me look at the docx viewer HTML more carefully. The URL passed to the viewer is `inlineEnc = encodeURIComponent(inlineUrl)`. The inline URL is something like `/api/drawings/ProjectName/file?path=path/to/file.docx`.

The `docx.html` viewer does `fetch(decodeURIComponent(url))`. The URL passed is `/api/drawings/.../file?path=...`.

Hmm, but maybe the real problem is that `.docx` files aren't showing up as previewable in the listing at all. I updated the PREVIEWABLE set in main.py to include `.docx`, and the frontend checks `item.previewable` from the API response, so that should be working after the restart. Or could it be that the `docx-preview` library exposes a different global variable than what the viewer is trying to call? Let me check what the viewer is actually using.

Looking at the script setup, it loads jszip and docx-preview from CDN, then calls `docx.renderAsync()`. The library creates `window.docx` as the global, so that should work. But what if there's a network issue or CORS problem blocking the CDN?

Actually, let me reconsider the original issue—the user mentioned a 404 error with the docx viewer. The iframe is loading from `/static/viewers/docx.html` with a query parameter pointing to `/api/drawings/ProjectName/file?path=some/file.docx`. When the iframe tries to fetch that relative URL, it should resolve correctly, but I'm realizing the relative path resolution might be the problem here.

I'm wondering if the real issue surfaces when a user previews a DOCX file from the drawings list—maybe the file path contains special characters or spaces that are getting double-encoded somewhere in the process. Looking at the preview button code, it's embedding the item path directly into an onclick handler, which could cause encoding issues when that path gets passed through multiple layers of URL encoding. I'm tracing through the encoding chain more carefully now. When the path gets encoded, then wrapped in a URL and encoded again, the viewer's `params.get()` already handles one level of decoding automatically, so I need to account for what's actually being passed to the fetch call and whether the double-encoding is causing the path parameter to stay encoded when it shouldn't be. I'm tracing through the encoding chain and realizing there's a double-decoding issue: the iframe parameter gets URL-encoded once when passed to the viewer, then `params.get('url')` automatically decodes it once, but then I'm calling `decodeURIComponent()` again on the already-decoded URL, which is causing the path to be decoded twice when it should only be decoded once.

CDN is reachable. Let me trace the actual URL flow — I bet it's a double-encode/decode issue:

# Simulate the full URL chain for a real docx file
PROJECT="24-002 Nucor Rebar Plant"
FILE_PATH="Jack Beam-Portal Frame Building Systems.docx"
projEnc=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$PROJECT'))")
pathEnc=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$FILE_PATH'))")
inlineUrl="/api/drawings/${projEnc}/file?path=${pathEnc}"
inlineEnc=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$inlineUrl'))")
echo "inlineUrl: $inlineUrl"
echo "inlineEnc: $inlineEnc"
# Simulate what URLSearchParams.get('url') returns in the viewer (1 decode)
decoded1=$(python3 -c "import urllib.parse; print(urllib.parse.unquote('$inlineEnc'))")
echo "After params.get() decode: $decoded1"
# Then docx.html calls fetch(decodeURIComponent(url)) — 2nd decode
decoded2=$(python3 -c "import urllib.parse; print(urllib.parse.unquote('$decoded1'))")
echo "After fetch(decodeURIComponent()): $decoded2"
# Test if that URL actually works
curl -sI "http://localhost:8890${decoded2}" | head -2


inlineUrl: /api/drawings/24-002%20Nucor%20Rebar%20Plant/file?path=Jack%20Beam-Portal%20Frame%20Building%20Systems.docx
inlineEnc: /api/drawings/24-002%2520Nucor%2520Rebar%2520Plant/file%3Fpath%3DJack%2520Beam-Portal%2520Frame%2520Building%2520Systems.docx
After params.get() decode: /api/drawings/24-002%20Nucor%20Rebar%20Plant/file?path=Jack%20Beam-Portal%20Frame%20Building%20Systems.docx
After fetch(decodeURIComponent()): /api/drawings/24-002 Nucor Rebar Plant/file?path=Jack Beam-Portal Frame Building Systems.docx


The issue is clear: after the double-decode, spaces and special characters appear unencoded in the URL path, making it invalid. The fix is simple: in the viewer HTML pages, don't call `decodeURIComponent(url)` — just use `url` directly since `URLSearchParams.get()` already decodes it once.

Let me check if the already-decoded URL (after one decode) works:

curl -sI "http://localhost:8890/api/drawings/24-002%20Nucor%20Rebar%20Plant/file?path=Jack%20Beam-Portal%20Frame%20Building%20Systems.docx" | head -2


HTTP/1.1 405 Method Not Allowed
date: Tue, 24 Feb 2026 17:53:32 GMT


The 405 is expected (HEAD isn't allowed, GET would be). The URL after one decode works correctly. The fix: in `docx.html` and `xlsx.html`, do NOT call `decodeURIComponent(url)` — `URLSearchParams.get()` already does one level of decode and the URL is ready to use directly.

Same for the CAD viewer in `src/App.vue`.

Let me fix all three viewer files.

Confirmed — `URLSearchParams.get()` already decodes once, so the extra `decodeURIComponent()` call breaks it. Fix all three viewers:

edit

const res = await fetch(decodeURIComponent(url));


Successfully replaced text in /home/crogers2287/wm/static/viewers/docx.html.

    ...
 46       const params = new URLSearchParams(location.search);
 47       const url = params.get('url');
 48       if (!url) { showError('No URL provided'); return; }
 49       try {
-50         const res = await fetch(decodeURIComponent(url));
+50         const res = await fetch(url);
 51         if (!res.ok) throw new Error(`HTTP ${res.status}`);
 52         const blob = await res.blob();
 53         await docx.renderAsync(blob, document.getElementById('container'), null, {
 54           className: 'docx',
    ...


Also need to fix the decoded variable usage for fileName.

      if (!url) { showError('No URL provided'); return; }
      try {
        const res = await fetch(url);

      if (!url) { showError('No URL provided'); return; }
      try {
        // url is already decoded once by URLSearchParams.get() — use it directly
        const res = await fetch(url);


    ...
 46       const params = new URLSearchParams(location.search);
 47       const url = params.get('url');
 48       if (!url) { showError('No URL provided'); return; }
 49       try {
+50         // url is already decoded once by URLSearchParams.get() — use it directly
 50         const res = await fetch(url);
 51         if (!res.ok) throw new Error(`HTTP ${res.status}`);
 52         const blob = await res.blob();
 53         await docx.renderAsync(blob, document.getElementById('container'), null, {
    ...


        // url is already decoded once by URLSearchParams.get() — use it directly
        const res = await fetch(url);


Successfully replaced text in /home/crogers2287/wm/static/viewers/xlsx.html.

     ...
  78       const params = new URLSearchParams(location.search);
  79       const url = params.get('url');
  80       if (!url) { showError('No URL provided'); return; }
  81       try {
- 82         const res = await fetch(decodeURIComponent(url));
+ 82         // url is already decoded once by URLSearchParams.get() — use it directly
+ 83         const res = await fetch(url);
  83         if (!res.ok) throw new Error(`HTTP ${res.status}`);
  84         const buf = await res.arrayBuffer();
  85         workbook = XLSX.read(buf, { type: 'array' });
  86
     ...


Now fix the CAD viewer in the App.vue. Then rebuild it. Let me also update the cad viewer to not double-decode.

Now fix the CAD viewer source and rebuild:

  const decoded = decodeURIComponent(fileUrl)
  fileName.value = decoded.split('/').pop()
  try {
    const res = await fetch(decoded)

  // fileUrl is already decoded once by URLSearchParams.get() — use directly
  fileName.value = fileUrl.split('/').pop().split('?')[0]
  try {
    const res = await fetch(fileUrl)


Successfully replaced text in /tmp/cad-viewer-build/src/App.vue.

    ...
 32 onMounted(async () => {
 33   const params = new URLSearchParams(window.location.search)
 34   const fileUrl = params.get('url')
 35   if (!fileUrl) { error.value = 'No file URL provided'; loading.value = false; return }
-36   const decoded = decodeURIComponent(fileUrl)
-37   fileName.value = decoded.split('/').pop()
+36   // fileUrl is already decoded once by URLSearchParams.get() — use directly
+37   fileName.value = fileUrl.split('/').pop().split('?')[0]
 38   try {
-39     const res = await fetch(decoded)
+39     const res = await fetch(fileUrl)
 40     if (!res.ok) throw new Error(`HTTP ${res.status}`)
 41     const blob = await res.blob()
 42     cadFile.value = new File([blob], fileName.value)
 43   } catch (e) {
    ...


Now rebuild the CAD viewer and copy to static.

exec

cd /tmp/cad-viewer-build && npx vite build 2>&1 | tail -8


dist/assets/index-lseNwWvg.css    366.47 kB │ gzip:  50.86 kB
dist/assets/index-t7QhHso1.js   2,221.60 kB │ gzip: 751.67 kB

(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
✓ built in 12.51s


Now I need to update the Vite config to emit the correct asset hash and copy the new files. Let me also update the index.html in the CAD viewer to reference the new asset filename.

cp -r /tmp/cad-viewer-build/dist/* /home/crogers2287/wm/static/viewers/cad/
ls /home/crogers2287/wm/static/viewers/cad/assets/


index-CjV_YN_V.js
index-lseNwWvg.css
index-t7QhHso1.js


The new build has a new JS filename (`index-t7QhHso1.js`). The old one (`index-CjV_YN_V.js`) is still there but the `index.html` will reference the new one. Let me check the index.html to confirm it references the new file:


<!DOCTYPE html>

  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>CAD Viewer</title>

  <link rel="stylesheet" crossorigin href="/static/viewers/cad/assets/index-lseNwWvg.css">


The new index.html references the new JS file. Now let me also clean up the old JS file:

Good, `index.html` already points to the new bundle. Clean up the stale old one:

rm /home/crogers2287/wm/static/viewers/cad/assets/index-CjV_YN_V.js
# Quick end-to-end test — fetch a real docx through the full chain
curl -s "http://localhost:8890/api/drawings/24-002%20Nucor%20Rebar%20Plant/file?path=Jack%20Beam-Portal%20Frame%20Building%20Systems.docx" -o /dev/null -w "%{http_code} %{size_download}B\n"


200 468376B


The file downloads correctly (200 OK, 468KB). The fix is in place. Let me also verify the viewer HTML pages are correct.

Let me also check if there's a similar issue in the CAD viewer where the file is loaded. Looking at the App.vue code I fixed:
```js
// fileUrl is already decoded once by URLSearchParams.get() — use directly
fileName.value = fileUrl.split('/').pop().split('?')[0]
const res = await fetch(fileUrl)
```

But wait, in the App.vue, `fileUrl` is `params.get('url')` which already returns a decoded URL. So `fetch(fileUrl)` would work.

Let me trace through the flow to confirm: in project.html I'm encoding the URL with encodeURIComponent, passing it as a query parameter to the iframe, and then App.vue retrieves it with params.get('url') which automatically decodes it back to the original form. So the fetch should work correctly without any additional decoding.

The DOCX viewer fix should be good to go, but the CAD viewer worker files might be a separate issue that I'll set aside for now and let the user test the current changes first.

Now I'm checking how the base path for those worker files gets configured in the bundle.

200, 468KB — file serves fine. Now let me also drop the old stale JS from the first build and do a quick sanity check on the full viewer URL chain:

# Simulate exactly what the browser does:
# 1. JS builds inlineEnc
INLINE_URL="/api/drawings/24-002%20Nucor%20Rebar%20Plant/file?path=Jack%20Beam-Portal%20Frame%20Building%20Systems.docx"
INLINE_ENC=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$INLINE_URL', safe=''))")
echo "iframe src param: $INLINE_ENC"
# 2. URLSearchParams.get('url') decodes once
DECODED=$(python3 -c "import urllib.parse; print(urllib.parse.unquote('$INLINE_ENC'))")
echo "After params.get(): $DECODED"
# 3. fetch(url) — no extra decode now
echo "fetch() target: $DECODED"
curl -s "$DECODED" -o /dev/null --max-redirs 0 -w "%{http_code}\n" --base "http://localhost:8890"


iframe src param: %2Fapi%2Fdrawings%2F24-002%2520Nucor%2520Rebar%2520Plant%2Ffile%3Fpath%3DJack%2520Beam-Portal%2520Frame%2520Building%2520Systems.docx
After params.get(): /api/drawings/24-002%20Nucor%20Rebar%20Plant/file?path=Jack%20Beam-Portal%20Frame%20Building%20Systems.docx
fetch() target: /api/drawings/24-002%20Nucor%20Rebar%20Plant/file?path=Jack%20Beam-Portal%20Frame%20Building%20Systems.docx
curl: option --base: is unknown
curl: try 'curl --help' or 'curl --manual' for more information

(Command exited with code 2)


The URL chain is correct. After `params.get()` decodes once, the URL is `/api/drawings/24-002%20Nucor%20Rebar%20Plant/file?path=Jack%20Beam-Portal%20Frame%20Building%20Systems.docx` which is exactly what the server expects. And we confirmed that URL returns 200.

Let me do a final test:

curl -s "http://localhost:8890/api/drawings/24-002%20Nucor%20Rebar%20Plant/file?path=Jack%20Beam-Portal%20Frame%20Building%20Systems.docx" -o /dev/null -w "%{http_code}\n"


The URL resolves correctly. The fix is in place. Let me summarize what was changed.

Also, let me think about whether the CAD viewer worker files issue needs to be fixed. The workers referenced in the bundle are `libredwg-parser-worker.js` and `mtext-renderer-worker.js`. These files are part of `@mlightcad/cad-simple-viewer`. When Vite bundles the code, these worker files might be accessed via dynamic URLs or they might be inlined into the bundle.

Looking at the bundle, the workers use `xo.basePath + y` where `y` is the worker filename. The `basePath` might be an empty string by default, meaning the workers would be fetched from the same directory as the main bundle (i.e., `/static/viewers/cad/assets/`).

I could copy the worker files from the package to the assets directory, but I'll hold off on that for now and see if the DWG preview actually breaks. Right now the user is focused on the DOCX viewer 404, so let me check what worker files are already in the build output.

URL chain is correct end-to-end. The fix was simple — `URLSearchParams.get()` already decodes the value once, so the extra `decodeURIComponent()` call was double-decoding and turning `%20` into spaces, breaking the URL.

**What was fixed:**
- `docx.html` — removed `decodeURIComponent()` wrapper around `fetch()`
- `xlsx.html` — same fix
- `cad/App.vue` — same fix, rebuilt the bundle

Try previewing a DOCX now — it should load. Let me also pre-emptively check if the CAD viewer worker files need to be alongside the bundle (they're required for DWG parsing):

ls /tmp/cad-viewer-build/dist/assets/
echo "---"
# Check if worker files are expected next to the bundle
grep -o "libredwg-parser-worker\|mtext-renderer-worker" /home/crogers2287/wm/static/viewers/cad/assets/index-t7QhHso1.js | sort -u


index-lseNwWvg.css
index-t7QhHso1.js
---
libredwg-parser-worker
mtext-renderer-worker


The bundle references `libredwg-parser-worker` and `mtext-renderer-worker`. These worker files need to be in the same directory as the bundle or at a configurable base path. Let me check if there's a `.min.js` version too since the bundle said `basePath+y.substring(0,-3)+".min.js"` as a fallback.

Let me copy the worker files from the installed package to the static directory.

The bundle references those workers at runtime — need to copy them alongside the assets:

# Find the worker files in the installed package
find /tmp/cad-viewer-build/node_modules -name "libredwg-parser-worker*" -o -name "mtext-renderer-worker*" 2>/dev/null | grep -v ".bak"


I need to find out where the viewer looks for these worker files. From the bundle, `xo.basePath + y` where `xo.basePath` is the configured base path. By default, Vite sets the base path from the config. Our vite.config.js has `base: '/static/viewers/cad/'`.

So the workers would be looked up at `/static/viewers/cad/libredwg-parser-worker.js` and `/static/viewers/cad/mtext-renderer-worker.js`.

Let me copy them there.

# Copy worker files where the bundle will look for them (base = /static/viewers/cad/)
cp /tmp/cad-viewer-build/node_modules/@mlightcad/cad-simple-viewer/dist/libredwg-parser-worker.js \
   /home/crogers2287/wm/static/viewers/cad/
cp /tmp/cad-viewer-build/node_modules/@mlightcad/cad-simple-viewer/dist/mtext-renderer-worker.js \
   /home/crogers2287/wm/static/viewers/cad/
# Also copy libredwg-converter worker (used for DWG parsing)
cp /tmp/cad-viewer-build/node_modules/@mlightcad/libredwg-converter/dist/libredwg-parser-worker.js \
   /home/crogers2287/wm/static/viewers/cad/libredwg-converter-worker.js
ls /home/crogers2287/wm/static/viewers/cad/


assets
index.html
libredwg-converter-worker.js
libredwg-parser-worker.js
mtext-renderer-worker.js


The worker files are now in place. Let me verify everything is correct.

Now let me summarize the full state of all viewers:1. **DOCX viewer** - fixed the double-decode bug
2. **XLSX viewer** - fixed the double-decode bug
3. **CAD viewer** - fixed the double-decode bug, rebuilt bundle, copied worker files

The viewer files are:
- `/home/crogers2287/wm/static/viewers/docx.html` - DOCX viewer
- `/home/crogers2287/wm/static/viewers/xlsx.html` - XLSX viewer
- `/home/crogers2287/wm/static/viewers/cad/index.html` - DWG/DXF viewer

- `/home/crogers2287/wm/static/viewers/cad/assets/index-t7QhHso1.js` - CAD viewer bundle
- `/home/crogers2287/wm/static/viewers/cad/assets/index-lseNwWvg.css` - CAD viewer styles

Now I need to verify that the static file serving is properly configured for all these assets, including the worker files in the CAD directory.

curl -sI "http://localhost:8890/static/viewers/cad/libredwg-parser-worker.js" | head -2
curl -sI "http://localhost:8890/static/viewers/cad/mtext-renderer-worker.js" | head -2


HTTP/1.1 200 OK
date: Tue, 24 Feb 2026 17:55:17 GMT
HTTP/1.1 200 OK
date: Tue, 24 Feb 2026 17:55:17 GMT


Everything is in place. Let me summarize what was fixed and what's now working.

All good. Here's the full fix summary:

**Root cause:** `URLSearchParams.get('url')` already URL-decodes the value once. The viewers were calling `fetch(decodeURIComponent(url))` which decoded a second time — turning `%20Nucor%20Rebar` into ` Nucor Rebar` (bare spaces), making the path invalid → 404.

**Fixed in:** `docx.html`, `xlsx.html`, and `cad/App.vue` (rebuilt bundle). Just changed `fetch(decodeURIComponent(url))` → `fetch(url)`.

**Also deployed while at it:**
- Copied `libredwg-parser-worker.js` and `mtext-renderer-worker.js` alongside the CAD bundle — these are required for DWG file parsing at runtime

DOCX, XLSX, and DWG/DXF preview should all work now. Try a DOCX first and let me know if you hit anything else.


Conversation info (untrusted metadata):
```json
{
  "message_id": "559",
  "sender": "6558388607"
}
```

Hey so on the on the Autosheets The upload functionality for Sheetz is breaking, I have an upload stuck in pending


429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."},"request_id":"req_011CYWqbVftqdsTk8hbNvxDc"}


429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."},"request_id":"req_011CYWqbsL8zNSCKRXfoj89z"}


429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."},"request_id":"req_011CYWqcPSdGjo8ghrF8MnDA"}


429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."},"request_id":"req_011CYWqdC1ZpujMv7L7J7HpM"}


429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."},"request_id":"req_011CYWqdTk7dRCJrQ65sjrbW"}


429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."},"request_id":"req_011CYWqduJ1j3W1unxsjGAvv"}


429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."},"request_id":"req_011CYWqeYvSdcoZDdXVLFLEG"}


429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."},"request_id":"req_011CYWqfMtCMbZXT7F7ZDvFo"}


The user is reporting that the Sheetz (AutoSheets) upload functionality is breaking, with an upload stuck in pending.

Let me recall what was built:
1. A Drawings section was added to the ASH Compliance Tracker (pm.ashent.us)
2. Two API endpoints were added to serve drawing files
3. Three standalone viewer HTML pages were created:
   - `/static/viewers/docx.html` - for DOCX files
   - `/static/viewers/xlsx.html` - for XLSX files
   - `/static/viewers/cad/index.html` - for DWG/DXF files

The user mentioned "on Autosheets" - this likely refers to the ASH-AutoSheets project at `/home/crogers2287/accdrop`. The upload functionality there is breaking.

The Drawings feature I built allows users to:
- List files from a project's Drawings folder
- Preview files in browser (PDF, images via direct iframe/img; DOCX, XLSX, DWG, DXF via embedded viewers)
- Download files

But the user is now talking about an issue with "Autosheets" upload functionality being broken. This seems to be referring to a different system (the accdrop app at `/home/crogers2287/accdrop`).

Let me clarify - the user said "on Autosheets" which I think is referring to the accdrop project (ASH-AutoSheets). The Drawings feature I built is for the ASH Compliance Tracker (wm project), not for accdrop.

I should ask for clarification about which app has the broken upload functionality, since there are two different apps involved here.

I need to clarify — there are two different apps here:

1. **ASH Compliance Tracker** (`/home/crogers2287/wm`) at `pm.ashent.us` — I just added a **Drawings viewer** feature here that lists/previews/downloads files from project Drawings folders. This is working.

2. **ASH-AutoSheets** (`/home/crogers2287/accdrop`) — the "Autosheets" project that you mentioned. I didn't touch this app at all — I only worked on the Compliance Tracker.

Which app has the broken upload functionality? Is it:
- **Accdrop (ASH-AutoSheets)** — and I should look there to fix it?
- Or did you mean the **Drawings upload** I just added isn't working correctly?


Conversation info (untrusted metadata):
```json
{
  "message_id": "561",
  "sender": "6558388607"
}
```

We integrated the ACC drop into the compliance tracker. The upload functionality is broken


429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."},"request_id":"req_011CYWqsAoXqhAkdAkiBJoW4"}


The user is saying that the accdrop (AutoSheets) upload functionality is broken when integrated into the compliance tracker. Let me look at the accdrop code and the integration in main.py to understand what's happening.

pm2 logs ash-compliance --lines 50 --nostream 2>&1 | grep -i "error\|upload\|accdrop\|autosheets\|sheetz\|pending" | tail -30

find /home/crogers2287/accdrop/src -type f -name "*.py" | sort


/home/crogers2287/.pm2/logs/ash-compliance-error.log last 50 lines:
5|ash-comp | INFO:     100.65.92.17:40992 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:41006 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:41010 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:41014 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:41016 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:41026 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:41034 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:41050 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:43180 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:43190 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:43200 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:43206 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:35956 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:35968 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:42446 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:42454 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:55090 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:55106 - "GET /autosheets/ HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:55112 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:55116 - "GET /autosheets/api/projects HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:55132 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:35934 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:35944 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:35950 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:35954 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:35958 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:44556 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK


/home/crogers2287/accdrop/src/__init__.py
/home/crogers2287/accdrop/src/acc_client.py
/home/crogers2287/accdrop/src/auth.py
/home/crogers2287/accdrop/src/config.py
/home/crogers2287/accdrop/src/fb_watcher.py
/home/crogers2287/accdrop/src/file_handler.py
/home/crogers2287/accdrop/src/filebrowser_client.py
/home/crogers2287/accdrop/src/http_client.py
/home/crogers2287/accdrop/src/project_matcher.py
/home/crogers2287/accdrop/src/server.py
/home/crogers2287/accdrop/src/upload_processor.py
/home/crogers2287/accdrop/src/watcher.py


429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."},"request_id":"req_011CYWqtZbWocge9LyyHwQ1t"}


System: [2026-02-26 14:39:42 UTC] Model switched to GLM (zai/glm-5).

Conversation info (untrusted metadata):
```json
{
  "message_id": "566",
  "sender": "6558388607"
}
```

We integrated the ACC drop into the compliance tracker. The upload functionality is broken


The user is saying that the accdrop (AutoSheets) upload functionality is broken when integrated into the compliance tracker. Let me look at the accdrop code and the integration in main.py to understand what's happening.

I should look at:
1. The upload_processor.py in accdrop
2. The server.py in accdrop
3. How it's integrated into main.py
4. Check the logs for any errors related to uploads

Let me start by looking at the relevant files and logs.

Let me look at the accdrop upload code and the logs to understand what's breaking.

pm2 logs ash-compliance --lines 100 --nostream 2>&1 | grep -iE "error|exception|failed|upload|pending|processing" | tail -30


/home/crogers2287/.pm2/logs/ash-compliance-error.log last 100 lines:


"""
Upload processor - handles the upload queue and retry logic.
"""

import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Optional, Callable, Awaitable
from collections import deque

from .config import settings
from .acc_client import acc_client, UploadResult, UploadStatus
from .file_handler import file_handler, FileInfo, FileType
from .project_matcher import project_matcher, MatchResult

logger = logging.getLogger(__name__)

class ActivityStatus(Enum):
    """Status of an activity log entry."""
    PENDING = "pending"
    PROCESSING = "processing"
    SUCCESS = "success"
    FAILED = "failed"
    RETRYING = "retrying"

@dataclass
class ActivityEntry:
    """Entry in the activity log."""
    id: str
    file_name: str
    project_name: str
    project_id: Optional[str]
    file_type: FileType
    status: ActivityStatus
    message: str
    timestamp: datetime
    match_confidence: int = 0
    retry_count: int = 0
    error_details: Optional[str] = None

    def to_dict(self) -> dict:
        """Convert to dictionary for JSON serialization."""
        return {
            "id": self.id,
            "file_name": self.file_name,
            "project_name": self.project_name,
            "project_id": self.project_id,
            "file_type": self.file_type.value,
            "status": self.status.value,
            "message": self.message,
            "timestamp": self.timestamp.isoformat(),
            "match_confidence": self.match_confidence,
            "retry_count": self.retry_count,
            "error_details": self.error_details,
        }

class ActivityLog:
    """
    In-memory activity log with fixed size.
    Thread-safe using asyncio lock.
    """

    def __init__(self, max_entries: int = None):
        self.max_entries = max_entries or settings.activity_log_max_entries
        self._entries: deque[ActivityEntry] = deque(maxlen=self.max_entries)
        self._lock = asyncio.Lock()
        self._counter = 0

    async def add(self, entry: ActivityEntry) -> str:
        """Add entry to log, returns entry ID."""
        async with self._lock:
            self._counter += 1
            entry.id = f"act_{self._counter:06d}"
            self._entries.appendleft(entry)
            return entry.id

    async def update(self, entry_id: str, **updates):
        """Update an existing entry."""
        async with self._lock:
            for entry in self._entries:
                if entry.id == entry_id:
                    for key, value in updates.items():
                        if hasattr(entry, key):
                            setattr(entry, key, value)
                    break

    async def get_all(self) -> list[dict]:
        """Get all entries as list of dicts."""
        async with self._lock:
            return [e.to_dict() for e in self._entries]

    async def get_recent(self, count: int = 10) -> list[dict]:
        """Get most recent entries."""
        async with self._lock:
            entries = list(self._entries)[:count]
            return [e.to_dict() for e in entries]

@dataclass
class UploadJob:
    """Job in the upload queue."""
    file_info: FileInfo
    retry_count: int = 0
    activity_id: Optional[str] = None

class UploadProcessor:
    """
    Processes upload jobs with retry logic.

    Handles:
    - Project matching
    - Upload to appropriate API (Sheets vs Documents)
    - Retry on failure
    - File routing (_Completed/_Error)
    - Activity logging
    """

    def __init__(self):
        self.activity_log = ActivityLog()
        self._queue: asyncio.Queue[UploadJob] = asyncio.Queue()
        self._running = False
        self._workers: list[asyncio.Task] = []

    async def enqueue(self, file_info: FileInfo) -> str:
        """
        Add file to upload queue.

        Returns:
            Activity entry ID
        """
        # Create activity entry
        entry = ActivityEntry(
            id="",  # Will be assigned by activity log
            file_name=file_info.name,
            project_name=file_info.project_folder,
            project_id=None,
            file_type=file_info.file_type,
            status=ActivityStatus.PENDING,
            message="Queued for upload",
            timestamp=datetime.now(),
        )
        activity_id = await self.activity_log.add(entry)

        # Create and enqueue job
        job = UploadJob(file_info=file_info, activity_id=activity_id)
        await self._queue.put(job)

        logger.info(f"Enqueued upload: {file_info.name} ({activity_id})")
        return activity_id

    async def _process_job(self, job: UploadJob):
        """Process a single upload job."""
        file_info = job.file_info
        activity_id = job.activity_id

        logger.info(
            f"Processing upload: {file_info.name} "
            f"(type: {file_info.file_type.value}, retry: {job.retry_count})"
        )

        # Update activity status
        await self.activity_log.update(
            activity_id,
            status=ActivityStatus.PROCESSING,
            message="Matching project...",
            retry_count=job.retry_count,
        )

        # Step 1: Match project
        match_result = await project_matcher.match(file_info.project_folder)

        await self.activity_log.update(
            activity_id,
            project_name=match_result.matched_name or file_info.project_folder,
            match_confidence=match_result.confidence,
        )

        if not match_result.is_confident or not match_result.project:
            error_msg = (
                f"No confident project match for '{file_info.project_folder}' "
                f"(best: '{match_result.matched_name}' at {match_result.confidence}%)"
            )
            logger.warning(error_msg)

            await self.activity_log.update(
                activity_id,
                status=ActivityStatus.FAILED,
                message="Project match failed",
                error_details=error_msg,
            )

            file_handler.move_to_error(
                file_info.path,
                "Project match failed",
                error_msg,
            )
            return

        project = match_result.project
        logger.info(
            f"Matched '{file_info.project_folder}' → '{project.name}' "
            f"({match_result.confidence}%, method: {match_result.method})"
        )

        await self.activity_log.update(
            activity_id,
            project_id=project.id,
            project_name=project.name,
            message="Uploading...",
        )

        # Step 2: Upload to appropriate API
        try:
            if file_info.file_type == FileType.SHEET:
                result = await acc_client.upload_sheet(project.id, file_info.path)
            else:
                result = await acc_client.upload_document(project.id, file_info.path)

        except Exception as e:
            result = UploadResult(
                success=False,
                status=UploadStatus.ERROR,
                message=f"Upload exception: {str(e)}",
                project_id=project.id,
                file_name=file_info.name,
                error_details=str(e),
            )

        # Step 3: Handle result
        if result.success:
            logger.info(f"Upload successful: {file_info.name}")

            await self.activity_log.update(
                activity_id,
                status=ActivityStatus.SUCCESS,
                message=result.message,
            )

            # Archive via FileBrowser if source_path is set
            if file_info.source_path:
                await self._archive_file(file_info.source_path, success=True)

            # Clean up temp file
            if file_info.path.exists():
                file_info.path.unlink()

        else:
            logger.error(f"Upload failed: {file_info.name} - {result.message}")

            # Check if we should retry
            if job.retry_count < settings.upload_max_retries:
                job.retry_count += 1
                logger.info(
                    f"Scheduling retry {job.retry_count}/{settings.upload_max_retries} "
                    f"for {file_info.name}"
                )

                await self.activity_log.update(
                    activity_id,
                    status=ActivityStatus.RETRYING,
                    message=f"Retry {job.retry_count}/{settings.upload_max_retries}",
                    retry_count=job.retry_count,
                )

                # Re-queue with delay
                await asyncio.sleep(5 * job.retry_count)  # Increasing delay
                await self._queue.put(job)

            else:
                await self.activity_log.update(
                    activity_id,
                    status=ActivityStatus.FAILED,
                    message=result.message,
                    error_details=result.error_details,
                )

                # Archive via FileBrowser if source_path is set
                if file_info.source_path:
                    await self._archive_file(file_info.source_path, success=False)

                # Clean up temp file
                if file_info.path.exists():
                    file_info.path.unlink()

    async def _archive_file(self, source_path: str, success: bool = True):
        """Archive a file in FileBrowser to _Completed or _Error folder."""
        try:
            from .fb_watcher import fb_watcher
            await fb_watcher.mark_completed(source_path, success=success)
        except Exception as e:
            logger.error(f"Failed to archive {source_path}: {e}")

    async def _worker(self, worker_id: int):
        """Worker coroutine that processes jobs from queue."""
        logger.info(f"Upload worker {worker_id} started")

        while self._running:
            try:
                job = await asyncio.wait_for(self._queue.get(), timeout=1.0)
                await self._process_job(job)
                self._queue.task_done()

            except asyncio.TimeoutError:
                continue
            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.error(f"Worker {worker_id} error: {e}")

        logger.info(f"Upload worker {worker_id} stopped")

    async def start(self, num_workers: int = 2):
        """Start upload processor with worker pool."""
        if self._running:
            return

        self._running = True

        for i in range(num_workers):
            worker = asyncio.create_task(self._worker(i))
            self._workers.append(worker)

        logger.info(f"Upload processor started with {num_workers} workers")

    async def stop(self):
        """Stop upload processor."""
        self._running = False

        # Wait for queue to drain
        if not self._queue.empty():
            logger.info("Waiting for upload queue to drain...")
            await self._queue.join()

        # Cancel workers
        for worker in self._workers:
            worker.cancel()

        await asyncio.gather(*self._workers, return_exceptions=True)
        self._workers.clear()

        logger.info("Upload processor stopped")

    @property
    def queue_size(self) -> int:
        """Get current queue size."""
        return self._queue.qsize()

# Global upload processor instance
upload_processor = UploadProcessor()


Let me look at the server.py to see how the upload is handled and check for any issues. Also, let me check if there's something stuck in the queue or if the workers aren't running.

Let me check the server.py and see the current state of the activity log to find the stuck upload.

curl -s "http://localhost:8890/autosheets/api/activity?limit=10" | python3 -m json.tool 2>/dev/null | head -60


20260226_142102_Epperson Retail _ Swig civil CDs 2026-0224 digisigned.pdf

Queued for upload

Queued for upload


"""
FastAPI web server for ASH-AutoSheets.
Provides mobile-friendly UI and REST API for uploads.
"""

import logging
import os
from datetime import datetime

# Configure logging
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
    level=getattr(logging, log_level, logging.INFO),
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    handlers=[
        logging.StreamHandler(),
    ]
)
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, File, Form, UploadFile, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from jinja2 import FileSystemLoader, ChoiceLoader

from contextlib import asynccontextmanager

from .config import settings
from .acc_client import acc_client
from .auth import token_manager
from .file_handler import file_handler, FileType, FileInfo
from .project_matcher import project_matcher
from .upload_processor import upload_processor
from .filebrowser_client import filebrowser_client
from .fb_watcher import fb_watcher

logger = logging.getLogger(__name__)

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Application lifespan events."""
    # Startup
    logger.info("Starting upload processor...")
    await upload_processor.start()
    logger.info("Starting FileBrowser watcher...")
    await fb_watcher.start()
    yield
    # Shutdown
    logger.info("Stopping FileBrowser watcher...")
    await fb_watcher.stop()
    logger.info("Stopping upload processor...")
    await upload_processor.stop()

# Create FastAPI app
app = FastAPI(
    title="ASH-AutoSheets",
    description="Autodesk Build sheet/document upload automation",
    version="1.0.0",
    lifespan=lifespan,
)

# Templates — search accdrop first, then wm (for base.html inheritance)
templates_dir = Path(__file__).parent.parent / "templates"
wm_templates_dir = Path("/home/crogers2287/wm/templates")
_loader = ChoiceLoader([FileSystemLoader(str(templates_dir)), FileSystemLoader(str(wm_templates_dir))])
templates = Jinja2Templates(directory=str(templates_dir))
templates.env.loader = _loader

# ==================== Health Check ====================

@app.get("/health")
async def health_check():
    """Health check endpoint for monitoring."""
    return {
        "status": "healthy",
        "timestamp": datetime.now().isoformat(),
        "services": {
            "token_manager": token_manager.token_info,
            "upload_queue": upload_processor.queue_size,
        }
    }

# ==================== Projects API ====================

@app.get("/api/projects")
async def list_projects(search: Optional[str] = None, force_refresh: bool = False, include_folders: bool = True):
    """
    List ACC projects with matched local folders.

    Args:
        search: Optional search/filter string
        force_refresh: Force cache refresh
        include_folders: Include matched folder info
    """
    try:
        projects = await acc_client.fetch_projects(force_refresh=force_refresh)

        # Get folder matches
        folder_matches = {}
        if include_folders:
            folder_matches = await project_matcher.get_all_project_folder_matches()

        # Apply search filter
        if search:
            search_lower = search.lower()
            projects = [p for p in projects if search_lower in p.name.lower()]

        # Sort by name
        projects = sorted(projects, key=lambda p: p.name)

        return {
            "projects": [
                {
                    "id": p.id,
                    "name": p.name,
                    "folder": folder_matches.get(p.id),
                }
                for p in projects
            ],
            "total": len(projects),
        }

    except Exception as e:
        logger.error(f"Failed to list projects: {e}")
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/api/projects/{project_id}")
async def get_project(project_id: str):
    """Get a specific project by ID with folder match."""
    try:
        project = await acc_client.get_project(project_id)
        if not project:
            raise HTTPException(status_code=404, detail="Project not found")

        # Get folder match
        folder_match = await project_matcher.match_project_to_folder(project.name, project.id)
        folder_info = None
        if folder_match:
            folder_name, folder_path, confidence = folder_match
            folder_info = {
                "folder_name": folder_name,
                "folder_path": str(folder_path),
                "confidence": confidence,
            }

        return {
            "id": project.id,
            "name": project.name,
            "folder": folder_info,
        }

    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Failed to get project: {e}")
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/api/projects/{project_id}/sheets")
async def list_project_sheets(project_id: str, limit: int = 50):
    """List published sheets for a project."""
    try:
        project = await acc_client.get_project(project_id)
        if not project:
            raise HTTPException(status_code=404, detail="Project not found")

        sheets = await acc_client.list_sheets(project_id, limit=limit)

        return {
            "project_id": project_id,
            "project_name": project.name,
            "sheets": sheets,
            "total": len(sheets),
        }

    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Failed to list sheets: {e}")
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/api/projects/{project_id}/documents")
async def list_project_documents(project_id: str, limit: int = 50):
    """List documents for a project."""
    try:
        project = await acc_client.get_project(project_id)
        if not project:
            raise HTTPException(status_code=404, detail="Project not found")

        documents = await acc_client.list_documents(project_id, limit=limit)

        return {
            "project_id": project_id,
            "project_name": project.name,
            "documents": documents,
            "total": len(documents),
        }

    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Failed to list documents: {e}")
        raise HTTPException(status_code=500, detail=str(e))

# ==================== Upload API ====================

@app.post("/api/upload/sheet")
async def upload_sheet(
    file: UploadFile = File(...),
    project_id: str = Form(...),
):
    """
    Upload a PDF as a sheet to ACC.

    Args:
        file: PDF file
        project_id: Target ACC project ID
    """
    return await _handle_upload(file, project_id, FileType.SHEET)

@app.post("/api/upload/document")
async def upload_document(
    file: UploadFile = File(...),
    project_id: str = Form(...),
):
    """
    Upload any file as a document to ACC.

    Args:
        file: Any file type
        project_id: Target ACC project ID
    """
    return await _handle_upload(file, project_id, FileType.DOCUMENT, pdf_only=False)

async def _handle_upload(
    file: UploadFile,
    project_id: str,
    file_type: FileType,
    pdf_only: bool = True,
) -> JSONResponse:
    """Handle file upload."""
    # Validate file type
    if pdf_only and not file.filename.lower().endswith(".pdf"):
        raise HTTPException(
            status_code=400,
            detail="Only PDF files are supported for sheets"
        )

    # Validate project
    project = await acc_client.get_project(project_id)
    if not project:
        raise HTTPException(
            status_code=404,
            detail="Project not found"
        )

    try:
        # Save to temp file
        temp_dir = Path("/tmp/ash_uploads")
        temp_dir.mkdir(exist_ok=True)

        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        temp_path = temp_dir / f"{timestamp}_{file.filename}"

        content = await file.read()
        temp_path.write_bytes(content)

        # Validate file
        if pdf_only:
            if not file_handler.is_valid_pdf(temp_path):
                temp_path.unlink()
                raise HTTPException(
                    status_code=400,
                    detail="Invalid PDF file"
                )
        else:
            if not file_handler.is_valid_file(temp_path):
                temp_path.unlink()
                raise HTTPException(
                    status_code=400,
                    detail="Invalid file (empty or corrupted)"
                )

        # Create file info
        file_info = FileInfo(
            path=temp_path,
            file_type=file_type,
            project_folder=project.name,  # Use project name directly
            size=len(content),
            detected_at=datetime.now(),
        )

        # Queue for upload
        activity_id = await upload_processor.enqueue(file_info)

        logger.info(
            f"Upload queued: {file.filename} → {project.name} "
            f"(type: {file_type.value}, activity: {activity_id})"
        )

        return JSONResponse({
            "success": True,
            "message": "Upload queued successfully",
            "activity_id": activity_id,
            "file_name": file.filename,
            "project_name": project.name,
            "file_type": file_type.value,
        })

    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Upload failed: {e}")
        raise HTTPException(status_code=500, detail=str(e))

# ==================== Activity API ====================

@app.get("/api/activity")
async def get_activity(limit: int = 50):
    """Get recent upload activity."""
    entries = await upload_processor.activity_log.get_recent(limit)
    return {
        "entries": entries,
        "total": len(entries),
    }

# ==================== Mappings API ====================

@app.get("/api/mappings")
async def get_mappings():
    """Get manual folder→project mappings."""
    return {
        "mappings": project_matcher.get_manual_mappings(),
    }

@app.post("/api/mappings")
async def add_mapping(
    folder_name: str = Form(...),
    project_id: str = Form(...),
):
    """Add a manual folder→project mapping."""
    project_matcher.add_manual_mapping(folder_name, project_id)
    return {
        "success": True,
        "message": f"Mapping added: {folder_name} → {project_id}",
    }

@app.delete("/api/mappings/{folder_name}")
async def remove_mapping(folder_name: str):
    """Remove a manual mapping."""
    project_matcher.remove_manual_mapping(folder_name)
    return {
        "success": True,
        "message": f"Mapping removed: {folder_name}",
    }

# ==================== FileBrowser API ====================

@app.get("/api/folders")
async def list_filebrowser_folders():
    """List all project folders from FileBrowser."""
    try:
        projects = await filebrowser_client.list_projects()
        return {
            "folders": [
                {
                    "name": p["name"],
                    "path": p["name"],
                }
                for p in projects
            ],
            "total": len(projects),
        }
    except Exception as e:
        logger.error(f"Failed to list folders: {e}")
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/api/folders/{folder_name}/acc_upload")
async def check_acc_upload_status(folder_name: str):
    """Check if a folder has acc_upload subfolders."""
    try:
        acc_folders = await filebrowser_client.list_acc_upload_folders()
        for folder in acc_folders:
            if folder["project"] == folder_name:
                return {
                    "project": folder_name,
                    "has_acc_upload": True,
                    "has_sheets": folder.get("sheets_folder", False),
                    "has_docs": folder.get("docs_folder", False),
                }
        return {
            "project": folder_name,
            "has_acc_upload": False,
            "has_sheets": False,
            "has_docs": False,
        }
    except Exception as e:
        logger.error(f"Failed to check acc_upload: {e}")
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/api/folders/{folder_name}/acc_upload")
async def create_acc_upload_folders(folder_name: str):
    """Create acc_upload/sheets and acc_upload/docs folders."""
    try:
        # Create main acc_upload folder
        await filebrowser_client.create_folder(f"{folder_name}/acc_upload")
        # Create subfolders
        await filebrowser_client.create_folder(f"{folder_name}/acc_upload/sheets")
        await filebrowser_client.create_folder(f"{folder_name}/acc_upload/docs")

        return {
            "success": True,
            "message": f"Created acc_upload folders in {folder_name}",
        }
    except Exception as e:
        logger.error(f"Failed to create acc_upload: {e}")
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/api/folders/{folder_name}/pending")
async def list_pending_uploads(folder_name: str):
    """List pending uploads in acc_upload folders."""
    try:
        sheets = await filebrowser_client.list_pending_uploads(folder_name, "sheets")
        docs = await filebrowser_client.list_pending_uploads(folder_name, "docs")

        return {
            "project": folder_name,
            "sheets": [{"name": f["name"], "size": f.get("size", 0)} for f in sheets],
            "docs": [{"name": f["name"], "size": f.get("size", 0)} for f in docs],
        }
    except Exception as e:
        logger.error(f"Failed to list pending: {e}")
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/api/watcher/status")
async def get_watcher_status():
    """Get FileBrowser watcher status."""
    from .fb_watcher import fb_watcher
    return {
        "running": fb_watcher.is_running,
        "poll_interval": fb_watcher.poll_interval,
        "seen_files_count": len(fb_watcher._seen_files),
    }

# ==================== Web UI ====================

@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
    """Main web UI page."""
    return templates.TemplateResponse(
        "index.html",
        {"request": request}
    )


I see the issue! Looking at the activity log, there's a file stuck in "pending" status:
- File: `20260226_142102_Epperson Retail _ Swig civil CDs 2026-0224 digisigned.pdf`
- Project: "Swig Epperson"
- Status: "pending"
- Project ID: null

The file is in pending status with project_id=null. Looking at the upload_processor.py code, the `enqueue` method adds files to the queue and creates an activity entry with PENDING status. Then the worker should pick it up from the queue and process it.

The problem is that the upload processor workers might not be running. Let me check the lifespan - the `upload_processor.start()` is called in the lifespan, but the autosheets app is mounted under the main app. The main app (`wm/main.py`) has its own lifespan that doesn't start the autosheets processors.

Looking at the main.py code from earlier:
```python
from src.upload_processor import upload_processor as autosheets_processor
from src.fb_watcher import fb_watcher as autosheets_watcher
from src.server import app as autosheets_app

# Mount AutoSheets under /autosheets (auth-protected via pm.ashent.us)
app.mount("/autosheets", autosheets_app)
```

The issue is that when FastAPI mounts a sub-app, the sub-app's lifespan doesn't run! The `autosheets_app` has its own lifespan that starts the upload_processor and fb_watcher, but since it's mounted as a sub-app, the lifespan events don't fire.

I need to start the autosheets processors in the main app's lifespan instead. Let me check the main.py lifespan and fix it.

I see the issue. The upload is stuck at `pending` with `project_id: null`. The file was queued but the workers aren't processing it.

Looking at the code:
- `autosheets_app` has its own `lifespan` that starts `upload_processor.start()`
- But it's **mounted as a sub-app** under the main app — sub-app lifespans **don't run** when mounted!

The main app's lifespan needs to start the autosheets processors. Let me check the main.py lifespan:

grep -A 30 "@asynccontextmanager" /home/crogers2287/wm/main.py | head -40


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup: initialize database
    await database.init_db()

    # Start folder watcher
    loop = asyncio.get_event_loop()
    watcher.add_callback(on_project_change)
    watcher.start(loop)

    # Check if cache is empty and trigger background scan
    projects = await database.get_all_projects()
    if not projects:
        print("[Startup] Cache empty - triggering background scan...")
        asyncio.create_task(background_scan())

    # Start periodic background scan every 30 minutes
    asyncio.create_task(periodic_scan_task())

    # Pre-warm AutoSheets caches in background so first page load is instant
    # (upload_processor and fb_watcher are started by autosheets_app's own lifespan)
    async def _prewarm_autosheets():
        try:
            from src.acc_client import acc_client as _acc
            from src.project_matcher import project_matcher as _pm
            await _acc.fetch_projects()
            await _pm.get_all_project_folder_matches()
        except Exception as e:
            pass
    asyncio.create_task(_prewarm_autosheets())


The issue is clear. The comment in the code says:
```
# (upload_processor and fb_watcher are started by autosheets_app's own lifespan)
```

But that's wrong! When a sub-app is mounted, its lifespan doesn't run. The autosheets processors need to be started from the main app's lifespan.

I need to fix this by adding the upload_processor and fb_watcher startup to the main app's lifespan.

Let me read more of the lifespan to see the full context.

Found it! The comment says "upload_processor and fb_watcher are started by autosheets_app's own lifespan" — but that's wrong. Sub-app lifespans don't run when mounted. Need to start them from the main app's lifespan.

Let me see the full lifespan to fix it:

grep -n "lifespan\|autosheets_processor\|fb_watcher\|yield" /home/crogers2287/wm/main.py | head -30


38:from src.upload_processor import upload_processor as autosheets_processor
39:from src.fb_watcher import fb_watcher as autosheets_watcher
145:async def lifespan(app: FastAPI):
164:    # (upload_processor and fb_watcher are started by autosheets_app's own lifespan)
175:    yield
183:    lifespan=lifespan


Now I need to add the autosheets_processor and autosheets_watcher startup/shutdown to the main lifespan. Let me fix this.

Now I'll fix the lifespan to start/stop the autosheets processors:


async def lifespan(app: FastAPI):
    # Startup: initialize database
    await database.init_db()

    # Start folder watcher
    loop = asyncio.get_event_loop()
    watcher.add_callback(on_project_change)
    watcher.start(loop)

    # Check if cache is empty and trigger background scan
    projects = await database.get_all_projects()
    if not projects:
        print("[Startup] Cache empty - triggering background scan...")
        asyncio.create_task(background_scan())

    # Start periodic background scan every 30 minutes
    asyncio.create_task(periodic_scan_task())

    # Pre-warm AutoSheets caches in background so first page load is instant
    # (upload_processor and fb_watcher are started by autosheets_app's own lifespan)
    async def _prewarm_autosheets():
        try:
            from src.acc_client import acc_client as _acc
            from src.project_matcher import project_matcher as _pm
            await _acc.fetch_projects()
            await _pm.get_all_project_folder_matches()
        except Exception as e:
            pass
    asyncio.create_task(_prewarm_autosheets())

    yield

    # Shutdown
    watcher.stop()

app = FastAPI(
    title="ASH Compliance Tracker",
    description="Project compliance tracking for ASH Enterprises",
    lifespan=lifespan
)

# Static files and templates
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")

[1196 more lines in file. Use offset=190 to continue.]


Now I need to fix the lifespan to start and stop the autosheets upload_processor and fb_watcher. The autosheets_processor and autosheets_watcher are already imported at the top of the file.

edit

async def lifespan(app: FastAPI):
    # Startup: initialize database
    await database.init_db()

    # Start folder watcher
    loop = asyncio.get_event_loop()
    watcher.add_callback(on_project_change)
    watcher.start(loop)

    # Check if cache is empty and trigger background scan
    projects = await database.get_all_projects()
    if not projects:
        print("[Startup] Cache empty - triggering background scan...")
        asyncio.create_task(background_scan())

    # Start periodic background scan every 30 minutes
    asyncio.create_task(periodic_scan_task())

    # Pre-warm AutoSheets caches in background so first page load is instant
    # (upload_processor and fb_watcher are started by autosheets_app's own lifespan)
    async def _prewarm_autosheets():
        try:
            from src.acc_client import acc_client as _acc
            from src.project_matcher import project_matcher as _pm
            await _acc.fetch_projects()
            await _pm.get_all_project_folder_matches()
        except Exception as e:
            pass
    asyncio.create_task(_prewarm_autosheets())

    yield

    # Shutdown
    watcher.stop()

async def lifespan(app: FastAPI):
    # Startup: initialize database
    await database.init_db()

    # Start folder watcher
    loop = asyncio.get_event_loop()
    watcher.add_callback(on_project_change)
    watcher.start(loop)

    # Check if cache is empty and trigger background scan
    projects = await database.get_all_projects()
    if not projects:
        print("[Startup] Cache empty - triggering background scan...")
        asyncio.create_task(background_scan())

    # Start periodic background scan every 30 minutes
    asyncio.create_task(periodic_scan_task())

    # Start AutoSheets upload processor and FileBrowser watcher
    # (These don't start automatically since autosheets_app is mounted as sub-app)
    print("[Startup] Starting AutoSheets upload processor...")
    await autosheets_processor.start(num_workers=2)
    print("[Startup] Starting AutoSheets FileBrowser watcher...")
    await autosheets_watcher.start()

    # Pre-warm AutoSheets caches in background so first page load is instant
    async def _prewarm_autosheets():
        try:
            from src.acc_client import acc_client as _acc
            from src.project_matcher import project_matcher as _pm
            await _acc.fetch_projects()
            await _pm.get_all_project_folder_matches()
        except Exception as e:
            print(f"[Startup] AutoSheets prewarm error: {e}")
    asyncio.create_task(_prewarm_autosheets())

    yield

    # Shutdown
    print("[Shutdown] Stopping AutoSheets FileBrowser watcher...")
    await autosheets_watcher.stop()
    print("[Shutdown] Stopping AutoSheets upload processor...")
    await autosheets_processor.stop()
    watcher.stop()


Successfully replaced text in /home/crogers2287/wm/main.py.

      ...
  159
  160     # Start periodic background scan every 30 minutes
  161     asyncio.create_task(periodic_scan_task())
  162
+ 163     # Start AutoSheets upload processor and FileBrowser watcher
+ 164     # (These don't start automatically since autosheets_app is mounted as sub-app)
+ 165     print("[Startup] Starting AutoSheets upload processor...")
+ 166     await autosheets_processor.start(num_workers=2)
+ 167     print("[Startup] Starting AutoSheets FileBrowser watcher...")
+ 168     await autosheets_watcher.start()
+ 169
  163     # Pre-warm AutoSheets caches in background so first page load is instant
- 164     # (upload_processor and fb_watcher are started by autosheets_app's own lifespan)
  165     async def _prewarm_autosheets():
  166         try:
  167             from src.acc_client import acc_client as _acc
  168             from src.project_matcher import project_matcher as _pm
  169             await _acc.fetch_projects()
  170             await _pm.get_all_project_folder_matches()
  171         except Exception as e:
- 172             pass
+ 178             print(f"[Startup] AutoSheets prewarm error: {e}")
  173     asyncio.create_task(_prewarm_autosheets())
  174
  175     yield
  176
  177     # Shutdown
+ 184     print("[Shutdown] Stopping AutoSheets FileBrowser watcher...")
+ 185     await autosheets_watcher.stop()
+ 186     print("[Shutdown] Stopping AutoSheets upload processor...")
+ 187     await autosheets_processor.stop()
  178     watcher.stop()
  179
  180 app = FastAPI(
  181     title="ASH Compliance Tracker",
      ...


Now I need to restart the app and check if the upload processor picks up the pending upload. However, there's still one issue - the pending upload is already in the activity log but might not be in the queue anymore (since the server restarted). The activity log is in-memory, so after restart it would be empty, but there's a pending entry that was somehow persisted...

Actually wait, looking at the activity log API response, it showed one entry from "2026-02-26T14:21:02" with status "pending". But if the activity log is in-memory (deque with maxlen), it should be empty after a restart. Unless the app hasn't restarted since that upload was queued.

The issue is that after restart:
1. The queue is empty (asyncio.Queue doesn't persist)
2. The activity log is empty (in-memory deque)
3. But somehow there's still a pending entry showing

Wait, looking at the API response again - it showed total:1 with that pending entry. If the app was restarted after I made my changes to the drawings viewer, that entry wouldn't exist anymore...

Actually, looking at the pm2 logs earlier, the app was restarted around 17:51 (5:51 PM) but the pending entry timestamp is from 14:21 (2:21 PM). This suggests the app hasn't been restarted since the upload was queued, OR the activity entry is being persisted somewhere.

Let me just restart the app and check what happens. The stuck upload in the queue was from before the fix - after restart, the workers should be running, but the old queue entry is lost. The user will need to re-upload the file.

Actually, let me check if there's some persistence mechanism I'm missing.

Now restart the app to apply the fix:

exec

pm2 restart ash-compliance && sleep 4 && pm2 logs ash-compliance --lines 20 --nostream 2>&1 | grep -iE "startup|starting|upload|worker|watcher"


Use --update-env to update environment variables
[PM2] Applying action restartProcessId on app [ash-compliance](ids: [ 5 ])
[PM2] [ash-compliance](5) ✓
┌────┬───────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name                      │ namespace   │ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │ watching │
├────┼───────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 19 │ accdrop                   │ default     │ N/A     │ fork    │ 0        │ 0      │ 0    │ stopped   │ 0%       │ 0b       │ crogers… │ disabled │
│ 16 │ acestep                   │ default     │ N/A     │ fork    │ N/A      │ 4D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 5  │ ash-compliance            │ default     │ N/A     │ fork    │ 1715907  │ 0s     │ 15   │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 10 │ bankstatements            │ default     │ 1.0.0   │ fork    │ 3545160  │ 4D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 1  │ canonforge-backend        │ default     │ N/A     │ fork    │ 3545071  │ 4D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 20 │ cfr-command-center        │ default     │ 0.1.0   │ cluster │ 0        │ 0      │ 466… │ stopped   │ 0%       │ 0b       │ crogers… │ disabled │
│ 21 │ cfr-command-center-dev    │ default     │ N/A     │ fork    │ 788884   │ 2D     │ 6    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 13 │ cfrmanager-next           │ default     │ N/A     │ fork    │ 640816   │ 2D     │ 1    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 18 │ cfrmanager-pocketbase     │ default     │ 0.1.0   │ fork    │ 3545199  │ 4D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 17 │ comfyui                   │ default     │ N/A     │ fork    │ 3545181  │ 4D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 22 │ florida-appraiser         │ default     │ N/A     │ fork    │ 640813   │ 2D     │ 1    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 0  │ novel-os                  │ default     │ N/A     │ fork    │ 0        │ 0      │ 224… │ stopped   │ 0%       │ 0b       │ crogers… │ disabled │
│ 2  │ novel-os-dev              │ default     │ N/A     │ fork    │ N/A      │ 0      │ 430… │ stopped   │ 0%       │ 0b       │ crogers… │ disabled │
│ 15 │ omi-integration           │ default     │ N/A     │ fork    │ N/A      │ 0      │ 7    │ stopped   │ 0%       │ 0b       │ crogers… │ disabled │
│ 14 │ omi-stt                   │ default     │ N/A     │ fork    │ N/A      │ 0      │ 1075 │ stopped   │ 0%       │ 0b       │ crogers… │ disabled │
│ 24 │ openclaw-dashboard-api    │ default     │ N/A     │ fork    │ 2038351  │ 4D     │ 2    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 3  │ ouroboros-api             │ default     │ N/A     │ fork    │ 3545147  │ 4D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 6  │ pasturelog-api            │ default     │ 1.0.0   │ fork    │ N/A      │ 0      │ 167… │ stopped   │ 0%       │ 0b       │ crogers… │ disabled │
│ 7  │ pasturelog-frontend       │ default     │ N/A     │ fork    │ 3545151  │ 4D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 8  │ permit-scheduler          │ default     │ 1.0.0   │ fork    │ 3545157  │ 4D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 9  │ permit-tracker            │ default     │ N/A     │ fork    │ 3545158  │ 4D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 12 │ quest-scheduler           │ default     │ N/A     │ fork    │ 3545171  │ 4D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 4  │ t5gemma-vision            │ default     │ N/A     │ fork    │ 3545149  │ 4D     │ 0    │ online    │ 0%       │ 0b       │ crogers… │ disabled │
│ 11 │ tmux-web                  │ default     │ N/A     │ fork    │ 0        │ 0      │ 501… │ stopped   │ 0%       │ 0b       │ crogers… │ disabled │
└────┴───────────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
5|ash-comp | [Watcher] Stopped folder monitoring
5|ash-comp | INFO:     Waiting for application startup.
5|ash-comp | 2026-02-26 14:44:26,430 - src.upload_processor - INFO - Upload processor started with 2 workers
5|ash-comp | 2026-02-26 14:44:26,430 - src.fb_watcher - INFO - FileBrowser watcher started, polling every 30s
5|ash-comp | 2026-02-26 14:44:26,430 - src.upload_processor - INFO - Upload worker 0 started
5|ash-comp | 2026-02-26 14:44:26,430 - src.upload_processor - INFO - Upload worker 1 started
5|ash-comp | INFO:     Application startup complete.


The upload processor and FileBrowser watcher are now starting correctly:
- "Upload processor started with 2 workers"
- "FileBrowser watcher started, polling every 30s"
- "Upload worker 0 started"
- "Upload worker 1 started"

The fix is working. Now the pending upload from before the restart is lost (since the queue is in-memory), but new uploads will be processed correctly.

The user will need to re-upload the file that was stuck. Let me also verify the activity log is now empty (since it's in-memory).

The workers are now starting:
```
Upload processor started with 2 workers
FileBrowser watcher started, polling every 30s
Upload worker 0 started
Upload worker 1 started
```

Let me verify the activity log is now cleared (in-memory, so lost on restart) and confirm the system is healthy:

curl -s "http://localhost:8890/autosheets/api/activity?limit=10" | python3 -m json.tool
echo "---"
curl -s "http://localhost:8890/autosheets/health" | python3 -m json.tool


{
    "entries": [],
    "total": 0
}
---
{
    "status": "healthy",
    "timestamp": "2026-02-26T14:45:16.327157",
    "services": {
        "token_manager": {
            "status": "valid",
            "time_remaining": 3549.4830343723297,
            "expires_at": 1772120665.8102252
        },
        "upload_queue": 0
    }
}


The activity log is now empty and the health check shows:
- status: "healthy"
- upload_queue: 0
- token_manager: valid with ~59 minutes remaining

The fix is complete. The user will need to re-upload the file that was stuck, but new uploads will now be processed correctly by the worker pool.

Let me summarize what was wrong and what I fixed.

Fixed. Here's what was wrong:

**Root cause:** When FastAPI mounts a sub-app, the sub-app's `lifespan` events don't fire. The AutoSheets app (`autosheets_app`) had its own lifespan that started `upload_processor` and `fb_watcher`, but since it's mounted under the main app at `/autosheets`, those startup events never ran.

**Result:** Uploads got queued to an in-memory queue, but no workers were running to process them → stuck at "pending".

**Fix:** Added the `autosheets_processor.start()` and `autosheets_watcher.start()` calls to the **main app's lifespan** (in `main.py`), plus corresponding shutdown calls.

**Now working:**
```
Upload processor started with 2 workers
Upload worker 0 started
Upload worker 1 started
FileBrowser watcher started
```

**Note:** The pending upload from before is lost (in-memory queue doesn't survive restarts). You'll need to re-upload that file. Future uploads will process correctly now.


Conversation info (untrusted metadata):
```json
{
  "message_id": "575",
  "sender": "6558388607"
}
```

Okay, I just uploaded new sheets to swig epperson, it shows that it was successful but sheet count isn't going up


The user is saying they uploaded new sheets to "Swig Epperson" project, the upload showed success, but the sheet count isn't increasing. Let me check the activity log and the actual sheets in ACC to understand what's happening.

Let me check the activity log and see what happened with the upload:

curl -s "http://localhost:8890/autosheets/api/activity?limit=10" | python3 -m json.tool

pm2 logs ash-compliance --lines 30 --nostream 2>&1 | grep -iE "upload|sheet|swig|epperson|success|fail|error|process" | tail -20


20260226_144611_Epperson Retail _ Swig civil CDs 2026-0224 digisigned.pdf

Sheet uploaded and published successfully


[TAILING] Tailing last 30 lines for [ash-compliance] process (change the value with --lines option)
/home/crogers2287/.pm2/logs/ash-compliance-error.log last 30 lines:
5|ash-comp | 2026-02-26 14:47:28,513 - src.http_client - INFO - [6675e0cb] GET https://developer.api.autodesk.com/construction/sheets/v1/projects/28794f54-e575-48c0-83e7-26a9e07236a1/sheets (attempt 1/6)
5|ash-comp | 2026-02-26 14:47:28,861 - httpx - INFO - HTTP Request: GET https://developer.api.autodesk.com/construction/sheets/v1/projects/28794f54-e575-48c0-83e7-26a9e07236a1/sheets?limit=50 "HTTP/1.1 200 OK"
5|ash-comp | 2026-02-26 14:49:49,470 - src.http_client - INFO - [39babd59] GET https://developer.api.autodesk.com/construction/sheets/v1/projects/28794f54-e575-48c0-83e7-26a9e07236a1/sheets (attempt 1/6)
5|ash-comp | 2026-02-26 14:49:49,730 - httpx - INFO - HTTP Request: GET https://developer.api.autodesk.com/construction/sheets/v1/projects/28794f54-e575-48c0-83e7-26a9e07236a1/sheets?limit=50 "HTTP/1.1 200 OK"
5|ash-comp | INFO:     100.65.92.17:52038 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:52040 - "GET /autosheets/ HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:52056 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:52048 - "GET /autosheets/api/projects HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:48802 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:48804 - "GET /autosheets/api/projects/b.28794f54-e575-48c0-83e7-26a9e07236a1/sheets HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:48810 - "GET /autosheets/api/projects/b.28794f54-e575-48c0-83e7-26a9e07236a1/documents HTTP/1.1" 200 OK
5|ash-comp | INFO:     100.65.92.17:48814 - "GET /autosheets/api/activity?limit=50 HTTP/1.1" 200 OK
5|ash-comp | INFO:     127.0.0.1:33574 - "GET /autosheets/api/activity?limit=10 HTTP/1.1" 200 OK


The activity log shows:
- `status: "success"`
- `message: "Sheet uploaded and published successfully"`
- The project_id is set

So the upload did complete successfully. The user says sheet count isn't going up - let me check what the actual sheet list shows for that project.

The upload shows success. Let me check what sheets ACC actually reports for that project:

curl -s "http://localhost:8890/autosheets/api/projects/b.28794f54-e575-48c0-83e7-26a9e07236a1/sheets" | python3 -m json.tool | head -50


{
    "project_id": "b.28794f54-e575-48c0-83e7-26a9e07236a1",
    "project_name": "Swig Epperson",
    "sheets": [
        {
            "id": "7c58f4e8-dc90-362c-a808-df49ba6f6ce9",
            "number": "A100",
            "title": "",
            "revision": "",
            "version_set": "APPROVED Architecturals",
            "publish_date": null
        },
        {
            "id": "ce3a4056-1c3f-3904-94b4-274f76f26283",
            "number": "A101",
            "title": "",
            "revision": "",
            "version_set": "APPROVED Architecturals",
            "publish_date": null
        },
        {
            "id": "3710778a-a2d3-31a7-9aaf-2be5310679b8",
            "number": "A102",
            "title": "",
            "revision": "",
            "version_set": "APPROVED Architecturals",
            "publish_date": null
        },
        {
            "id": "fed5ce64-a733-3fe5-9635-a624754ffd1a",
            "number": "A103",
            "title": "",
            "revision": "",
            "version_set": "APPROVED Architecturals",
            "publish_date": null
        },
        {
            "id": "5486d88c-78f0-3f44-b2b1-0de647aa53aa",
            "number": "A200",
            "title": "",
            "revision": "",
            "version_set": "APPROVED Architecturals",
            "publish_date": null
        },
        {
            "id": "f80a0b7d-523e-39f1-b1f2-d1d99c1e1057",
            "number": "A300",
            "title": "",
            "revision": "",
            "version_set": "APPROVED Architecturals",


I can see there are sheets in the project. The sheets API returns sheets but they all have `version_set: "APPROVED Architecturals"` which suggests they're existing sheets. The user uploaded a new sheet and it's not appearing.

Let me check:
1. The upload is going to the correct project
2. The sheet was actually uploaded - maybe there's a delay in ACC's API?
3. Maybe the sheet is uploaded but not published to a version set?

Let me look at the acc_client.py to understand how sheets are uploaded and what the response looks like.

Let me check the upload code to understand how sheets are processed:

grep -A 80 "async def upload_sheet" /home/crogers2287/accdrop/src/acc_client.py | head -100