From 38b67fca20d1640e89313ef3b0765a5ad124086b Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:23:57 +0000 Subject: [PATCH 01/45] =?UTF-8?q?=E2=9A=A1=20=EB=B3=91=EB=A0=AC=20?= =?UTF-8?q?=EC=99=B8=EB=B6=80=20=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EC=9D=84=20=EC=9C=84=ED=95=9C=20Promise.all?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server/app.mjs에서 첨부파일(attachments)의 PENDING 상태를 동기화할 때, 기존 for...of 루프 내부에서 jobStatus를 순차적으로 await 하던 로직을 Promise.all(rows.map(...))을 사용하도록 변경했습니다. 이를 통해 첨부파일이 여러 개일 경우 발생하는 네트워크 호출 병목을 효과적으로 줄이고 응답 지연을 방지합니다. --- .jules/bolt.md | 4 ++++ server/app.mjs | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index b08b203a..5c32c75c 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -4,3 +4,7 @@ ## 2026-07-12 - Optimize renderTaskRow DOM allocations **Learning:** Caching unattached template nodes and instantiating them via `.cloneNode(false)` reduces DOM instantiation overhead in O(N) render loops significantly. **Action:** Apply this optimization to other hot-path rendering elements such as rows, cells, and stack containers. + +## 2026-08-04 - Promise.all prevents blocking loop cascades +**Learning:** Sequential `for...of` loops awaiting external network requests inside Node.js block execution, causing high latency proportional to the array size. `Promise.all` alongside array mapping ensures I/O bounds are executed concurrently, greatly reducing the response time and not tying up the async context unnecessarily. +**Action:** Use `Promise.all(rows.map(...))` whenever looping over items and performing asynchronous external database/network calls. diff --git a/server/app.mjs b/server/app.mjs index 13d95e5d..62defe62 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1031,7 +1031,7 @@ app.get('/api/projects/:id/attachments', requireAuth, async (c) => { FROM attachments a LEFT JOIN users u ON u.id = a.created_by WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); // PENDING 잡 상태 갱신(최선 노력) - for (const r of rows) { + await Promise.all(rows.map(async (r) => { if (r.status === 'PENDING' || r.status === 'RUNNING') { try { const jid = db.prepare('SELECT job_id FROM attachments WHERE id = ?').get(r.id).job_id; @@ -1042,7 +1042,7 @@ app.get('/api/projects/:id/attachments', requireAuth, async (c) => { } } catch { /* keep stale status */ } } - } + })); return c.json({ attachments: rows }); }); From 5a9dfaaf10622ca8fa9c3c8042e740c534d3acff Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:32:09 +0000 Subject: [PATCH 02/45] =?UTF-8?q?=E2=9A=A1=20=EB=B3=91=EB=A0=AC=20?= =?UTF-8?q?=EC=99=B8=EB=B6=80=20=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EC=9D=84=20=EC=9C=84=ED=95=9C=20Promise.all?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20CVE=20=ED=8C=A8=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server/app.mjs에서 첨부파일(attachments)의 PENDING 상태를 동기화할 때, 기존 for...of 루프 내부에서 jobStatus를 순차적으로 await 하던 로직을 Promise.all(rows.map(...))을 사용하도록 변경했습니다. 이를 통해 첨부파일이 여러 개일 경우 발생하는 네트워크 호출 병목을 효과적으로 줄이고 응답 지연을 방지합니다. 추가로 CI Trivy 스캔에서 발견된 hono 패키지의 취약점(CVE-2026-69207)을 해결하기 위해 버전을 4.12.32에서 4.13.0으로 업데이트했습니다. --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 079e2031..859ec2a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "@hono/node-server": "^2.0.12", - "hono": "^4.12.32" + "hono": "^4.13.0" }, "devDependencies": { "@playwright/test": "1.61.1", @@ -382,9 +382,9 @@ } }, "node_modules/hono": { - "version": "4.12.32", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", - "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/package.json b/package.json index 7790e678..9258d65f 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@hono/node-server": "^2.0.12", - "hono": "^4.12.32" + "hono": "^4.13.0" }, "devDependencies": { "@playwright/test": "1.61.1", From a3c76d5f6ba7add9e76b802fc105c4c00292dbf1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 13:24:43 +0900 Subject: [PATCH 03/45] fix(review): bound job-status refresh concurrency to chunks of 5 Address CodeRabbit feedback: unbounded Promise.all over all pending attachments could exceed Clearfolio connection/rate limits. Filter to PENDING/RUNNING rows and process in chunks of 5, preserving best-effort stale-status handling. Also revise the .jules/bolt.md guidance to require bounded concurrency for external calls. Co-Authored-By: Claude Fable 5 --- .jules/bolt.md | 4 ++-- server/app.mjs | 13 ++++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index 5c32c75c..fddde4bb 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -6,5 +6,5 @@ **Action:** Apply this optimization to other hot-path rendering elements such as rows, cells, and stack containers. ## 2026-08-04 - Promise.all prevents blocking loop cascades -**Learning:** Sequential `for...of` loops awaiting external network requests inside Node.js block execution, causing high latency proportional to the array size. `Promise.all` alongside array mapping ensures I/O bounds are executed concurrently, greatly reducing the response time and not tying up the async context unnecessarily. -**Action:** Use `Promise.all(rows.map(...))` whenever looping over items and performing asynchronous external database/network calls. +**Learning:** Sequential `for...of` loops awaiting external network requests inside Node.js serialize I/O, causing latency proportional to the array size. Concurrent execution via `Promise.all` removes that, but an unbounded `Promise.all(rows.map(...))` starts every external call at once and can exhaust upstream connections or rate limits. +**Action:** Use plain `Promise.all` only for small fixed batches. For external database/network calls over arbitrarily sized arrays, bound concurrency (chunked `Promise.all` or a small worker pool) and keep per-item failure handling best-effort. diff --git a/server/app.mjs b/server/app.mjs index 62defe62..affd87ea 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1030,9 +1030,12 @@ app.get('/api/projects/:id/attachments', requireAuth, async (c) => { : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy FROM attachments a LEFT JOIN users u ON u.id = a.created_by WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); - // PENDING 잡 상태 갱신(최선 노력) - await Promise.all(rows.map(async (r) => { - if (r.status === 'PENDING' || r.status === 'RUNNING') { + // PENDING 잡 상태 갱신(최선 노력). Concurrent, but bounded so a large + // attachment list cannot open unbounded simultaneous Clearfolio calls. + // ponytail: fixed chunk size 5; make it configurable only if rate limits bite. + const pending = rows.filter((r) => r.status === 'PENDING' || r.status === 'RUNNING'); + for (let i = 0; i < pending.length; i += 5) { + await Promise.all(pending.slice(i, i + 5).map(async (r) => { try { const jid = db.prepare('SELECT job_id FROM attachments WHERE id = ?').get(r.id).job_id; const st = await jobStatus(p.org_id, uid, jid); @@ -1041,8 +1044,8 @@ app.get('/api/projects/:id/attachments', requireAuth, async (c) => { r.status = st; } } catch { /* keep stale status */ } - } - })); + })); + } return c.json({ attachments: rows }); }); From 99ff6ddd81ab35d619e1b427ae968b8a478564e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 14:59:46 +0900 Subject: [PATCH 04/45] ci: run one-shot attachment refresh hardening --- .../repair-attachment-status-refresh.yml | 563 ++++++++++++++++++ 1 file changed, 563 insertions(+) create mode 100644 .github/workflows/repair-attachment-status-refresh.yml diff --git a/.github/workflows/repair-attachment-status-refresh.yml b/.github/workflows/repair-attachment-status-refresh.yml new file mode 100644 index 00000000..fc23557a --- /dev/null +++ b/.github/workflows/repair-attachment-status-refresh.yml @@ -0,0 +1,563 @@ +name: Repair attachment status refresh + +on: + push: + branches: + - jules-promise-all-attachments-7315426299343398085 + +permissions: + contents: write + +concurrency: + group: repair-attachment-status-refresh + cancel-in-progress: true + +jobs: + repair: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: jules-promise-all-attachments-7315426299343398085 + fetch-depth: 0 + persist-credentials: true + + - name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 22.13.0 + cache: npm + + - name: Apply bounded refresh implementation and regression tests + shell: bash + run: | + set -euo pipefail + python3 <<'PY' + from pathlib import Path + import json + + def replace_once(path, old, new): + file_path = Path(path) + text = file_path.read_text(encoding='utf-8') + count = text.count(old) + if count != 1: + raise SystemExit(f'{path}: expected one replacement target, found {count}') + file_path.write_text(text.replace(old, new, 1), encoding='utf-8') + + attachment_status = r'''/** + * Default maximum number of concurrent Clearfolio status lookups. + * The limit protects the downstream service while keeping list latency bounded. + */ + export const ATTACHMENT_STATUS_DEFAULT_CONCURRENCY = 8; + + /** Conservative hard ceiling for operator-configured status lookup concurrency. */ + export const ATTACHMENT_STATUS_MAX_CONCURRENCY = 32; + + /** Default downstream status lookup timeout in milliseconds. */ + export const ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS = 3_000; + + /** Hard ceiling for the downstream status lookup timeout in milliseconds. */ + export const ATTACHMENT_STATUS_MAX_TIMEOUT_MS = 30_000; + + function normalizeBoundedInteger(value, fallback, maximum) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) return fallback; + return Math.min(parsed, maximum); + } + + /** + * Normalize the configured attachment-status worker count. + * + * @param {unknown} value - Environment or caller supplied value. + * @returns {number} An integer between 1 and 32, defaulting to 8. + */ + export function normalizeAttachmentStatusConcurrency(value) { + return normalizeBoundedInteger( + value, + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ATTACHMENT_STATUS_MAX_CONCURRENCY, + ); + } + + /** + * Normalize the configured Clearfolio status timeout. + * + * @param {unknown} value - Environment or caller supplied value. + * @returns {number} A positive timeout no greater than 30 seconds. + */ + export function normalizeAttachmentStatusTimeoutMs(value) { + return normalizeBoundedInteger( + value, + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ATTACHMENT_STATUS_MAX_TIMEOUT_MS, + ); + } + + function addRefreshMetrics(metrics, counts) { + if (!metrics) return; + const fields = { + attachmentStatusRefreshAttempted: 'attempted', + attachmentStatusRefreshChanged: 'changed', + attachmentStatusRefreshFailed: 'failed', + attachmentStatusRefreshDeferred: 'deferred', + }; + for (const [metric, count] of Object.entries(fields)) { + metrics[metric] = (Number(metrics[metric]) || 0) + counts[count]; + } + } + + /** + * Refresh pending attachment conversion statuses with a bounded worker pool. + * + * Rows are updated in place so the caller can serialize the refreshed public + * representation. Missing job identifiers and downstream failures preserve the + * stale status and never fail the attachment-list response. + * + * @param {Array} rows - Attachment rows containing `id`, `status`, and `jobId`. + * @param {object} options - Downstream functions, tenant identifiers, limits, and metrics. + * @param {number|string} options.orgId - ScopeWeave organization identifier. + * @param {number|string} options.userId - Requesting user identifier. + * @param {(orgId: unknown, userId: unknown, jobId: string, options: {signal: AbortSignal}) => Promise} options.jobStatus + * Downstream status lookup function. + * @param {(status: string, attachmentId: unknown) => unknown|Promise} options.updateStatus + * Persistence callback invoked only when the status changes. + * @param {unknown} [options.concurrency] - Maximum concurrent downstream lookups. + * @param {unknown} [options.timeoutMs] - Per-lookup timeout in milliseconds. + * @param {object} [options.metrics] - Mutable process metrics object. + * @returns {Promise<{attempted:number,changed:number,failed:number,deferred:number}>} + * Structured refresh counters for observability and tests. + */ + export async function refreshAttachmentStatuses(rows, options) { + if (!Array.isArray(rows)) throw new TypeError('rows must be an array'); + if (typeof options?.jobStatus !== 'function') throw new TypeError('jobStatus must be a function'); + if (typeof options?.updateStatus !== 'function') throw new TypeError('updateStatus must be a function'); + + const counts = { attempted: 0, changed: 0, failed: 0, deferred: 0 }; + const pending = rows.filter((row) => row?.status === 'PENDING' || row?.status === 'RUNNING'); + const concurrency = normalizeAttachmentStatusConcurrency(options.concurrency); + const timeoutMs = normalizeAttachmentStatusTimeoutMs(options.timeoutMs); + let cursor = 0; + + async function worker() { + for (;;) { + const index = cursor; + cursor += 1; + if (index >= pending.length) return; + const row = pending[index]; + const jobId = typeof row.jobId === 'string' ? row.jobId.trim() : ''; + if (!jobId) { + counts.deferred += 1; + continue; + } + + counts.attempted += 1; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const nextStatus = await options.jobStatus( + options.orgId, + options.userId, + jobId, + { signal: controller.signal }, + ); + if (typeof nextStatus !== 'string' || !nextStatus.trim()) { + throw new Error('invalid downstream status'); + } + if (nextStatus !== row.status) { + await options.updateStatus(nextStatus, row.id); + row.status = nextStatus; + counts.changed += 1; + } + } catch { + counts.failed += 1; + } finally { + clearTimeout(timer); + } + } + } + + const workerCount = Math.min(concurrency, pending.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + addRefreshMetrics(options.metrics, counts); + return counts; + } + '''.strip() + '\n' + Path('server/attachment_status.mjs').write_text(attachment_status, encoding='utf-8') + + replace_once( + 'server/app.mjs', + "import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';\n", + "import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';\nimport { normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs';\n", + ) + + replace_once( + 'server/app.mjs', + "const metrics = { startedAt: new Date().toISOString(), requests: 0, s2xx: 0, s4xx: 0, s5xx: 0, signups: 0, projectsCreated: 0, webhookDeliveries: 0 };", + "const metrics = {\n startedAt: new Date().toISOString(),\n requests: 0,\n s2xx: 0,\n s4xx: 0,\n s5xx: 0,\n signups: 0,\n projectsCreated: 0,\n webhookDeliveries: 0,\n attachmentStatusRefreshAttempted: 0,\n attachmentStatusRefreshChanged: 0,\n attachmentStatusRefreshFailed: 0,\n attachmentStatusRefreshDeferred: 0,\n};", + ) + + replace_once( + 'server/app.mjs', + "const ATTACH_MAX_BYTES = 10 * 1024 * 1024;\n", + "const ATTACH_MAX_BYTES = 10 * 1024 * 1024;\nconst ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency(\n process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY,\n);\nconst ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs(\n process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS,\n);\nconst updateAttachmentStatusStatement = db.prepare(\n 'UPDATE attachments SET status = ? WHERE id = ?',\n);\n", + ) + + old_route = r''' const rows = (taskId + ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) + : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); + // PENDING 잡 상태 갱신(최선 노력). Concurrent, but bounded so a large + // attachment list cannot open unbounded simultaneous Clearfolio calls. + // ponytail: fixed chunk size 5; make it configurable only if rate limits bite. + const pending = rows.filter((r) => r.status === 'PENDING' || r.status === 'RUNNING'); + for (let i = 0; i < pending.length; i += 5) { + await Promise.all(pending.slice(i, i + 5).map(async (r) => { + try { + const jid = db.prepare('SELECT job_id FROM attachments WHERE id = ?').get(r.id).job_id; + const st = await jobStatus(p.org_id, uid, jid); + if (st !== r.status) { + db.prepare('UPDATE attachments SET status = ? WHERE id = ?').run(st, r.id); + r.status = st; + } + } catch { /* keep stale status */ } + })); + } + return c.json({ attachments: rows });''' + new_route = r''' const rows = (taskId + ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) + : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); + await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + metrics, + }); + const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); + return c.json({ attachments });''' + replace_once('server/app.mjs', old_route, new_route) + + old_job_status = r'''export async function jobStatus(orgId, userId, jobId) { + if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; + const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { + headers: tenantHeaders(orgId, userId), + }); + const data = await res.json().catch(() => ({})); + return data.status || 'FAILED'; + }''' + new_job_status = r'''export async function jobStatus(orgId, userId, jobId, { signal } = {}) { + if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; + const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { + headers: tenantHeaders(orgId, userId), + signal, + }); + const data = await res.json().catch(() => ({})); + return data.status || 'FAILED'; + }''' + replace_once('server/clearfolio.mjs', old_job_status, new_job_status) + + unit_test = r'''import test from 'node:test'; + import assert from 'node:assert/strict'; + import { + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ATTACHMENT_STATUS_MAX_CONCURRENCY, + ATTACHMENT_STATUS_MAX_TIMEOUT_MS, + normalizeAttachmentStatusConcurrency, + normalizeAttachmentStatusTimeoutMs, + refreshAttachmentStatuses, + } from '../../server/attachment_status.mjs'; + + test('attachment status configuration is bounded and fail-safe', () => { + assert.equal(normalizeAttachmentStatusConcurrency(undefined), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); + assert.equal(normalizeAttachmentStatusConcurrency('4'), 4); + assert.equal(normalizeAttachmentStatusConcurrency(0), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); + assert.equal(normalizeAttachmentStatusConcurrency(1.5), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); + assert.equal(normalizeAttachmentStatusConcurrency(999), ATTACHMENT_STATUS_MAX_CONCURRENCY); + assert.equal(normalizeAttachmentStatusTimeoutMs(undefined), ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS); + assert.equal(normalizeAttachmentStatusTimeoutMs('25'), 25); + assert.equal(normalizeAttachmentStatusTimeoutMs(-1), ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS); + assert.equal(normalizeAttachmentStatusTimeoutMs(50_000), ATTACHMENT_STATUS_MAX_TIMEOUT_MS); + }); + + test('refresh validates its dependency contract', async () => { + await assert.rejects(() => refreshAttachmentStatuses(null, {}), /rows must be an array/); + await assert.rejects( + () => refreshAttachmentStatuses([], { updateStatus() {} }), + /jobStatus must be a function/, + ); + await assert.rejects( + () => refreshAttachmentStatuses([], { jobStatus() {} }), + /updateStatus must be a function/, + ); + }); + + test('empty and settled rows perform no downstream work', async () => { + const metrics = {}; + const counts = await refreshAttachmentStatuses( + [{ id: 1, status: 'SUCCEEDED', jobId: 'job-1' }], + { + jobStatus: async () => { throw new Error('must not run'); }, + updateStatus: () => { throw new Error('must not run'); }, + metrics, + }, + ); + assert.deepEqual(counts, { attempted: 0, changed: 0, failed: 0, deferred: 0 }); + assert.equal(metrics.attachmentStatusRefreshAttempted, 0); + assert.equal(metrics.attachmentStatusRefreshChanged, 0); + assert.equal(metrics.attachmentStatusRefreshFailed, 0); + assert.equal(metrics.attachmentStatusRefreshDeferred, 0); + }); + + test('100 pending rows respect the configured concurrency and persist only changes', async () => { + const rows = Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + jobId: `job-${index + 1}`, + status: index % 3 === 0 ? 'RUNNING' : 'PENDING', + })); + let active = 0; + let peak = 0; + const updates = []; + const metrics = { + attachmentStatusRefreshAttempted: 10, + attachmentStatusRefreshChanged: 20, + attachmentStatusRefreshFailed: 30, + attachmentStatusRefreshDeferred: 40, + }; + const counts = await refreshAttachmentStatuses(rows, { + orgId: 7, + userId: 9, + concurrency: 8, + timeoutMs: 1_000, + metrics, + jobStatus: async (orgId, userId, jobId, { signal }) => { + assert.equal(orgId, 7); + assert.equal(userId, 9); + assert.equal(signal.aborted, false); + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, Number(jobId.split('-')[1]) % 3)); + active -= 1; + return Number(jobId.split('-')[1]) % 2 === 0 ? 'SUCCEEDED' : rows[Number(jobId.split('-')[1]) - 1].status; + }, + updateStatus: async (status, attachmentId) => updates.push([status, attachmentId]), + }); + assert.ok(peak <= 8, `peak concurrency ${peak} exceeded configured limit`); + assert.deepEqual(counts, { attempted: 100, changed: 50, failed: 0, deferred: 0 }); + assert.equal(updates.length, 50); + assert.equal(metrics.attachmentStatusRefreshAttempted, 110); + assert.equal(metrics.attachmentStatusRefreshChanged, 70); + assert.equal(metrics.attachmentStatusRefreshFailed, 30); + assert.equal(metrics.attachmentStatusRefreshDeferred, 40); + }); + + test('invalid identifiers, downstream failures, invalid responses, and write failures remain isolated', async () => { + const rows = [ + { id: 1, jobId: null, status: 'PENDING' }, + { id: 2, jobId: '', status: 'RUNNING' }, + { id: 3, jobId: ' ', status: 'PENDING' }, + { id: 4, jobId: 'throws', status: 'PENDING' }, + { id: 5, jobId: 'invalid-status', status: 'PENDING' }, + { id: 6, jobId: 'write-fails', status: 'PENDING' }, + { id: 7, jobId: 'times-out', status: 'PENDING' }, + ]; + let aborted = false; + const counts = await refreshAttachmentStatuses(rows, { + concurrency: 3, + timeoutMs: 5, + jobStatus: async (_orgId, _userId, jobId, { signal }) => { + if (jobId === 'throws') throw new Error('downstream failure'); + if (jobId === 'invalid-status') return ''; + if (jobId === 'write-fails') return 'SUCCEEDED'; + return new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + aborted = true; + reject(new Error('aborted')); + }, { once: true }); + }); + }, + updateStatus: () => { throw new Error('write failure'); }, + }); + assert.equal(aborted, true); + assert.deepEqual(counts, { attempted: 4, changed: 0, failed: 4, deferred: 3 }); + assert.equal(rows[5].status, 'PENDING'); + assert.equal(rows[6].status, 'PENDING'); + }); + '''.strip() + '\n' + Path('tests/unit/attachment-status.test.mjs').write_text(unit_test, encoding='utf-8') + + signal_test = r'''import test from 'node:test'; + import assert from 'node:assert/strict'; + + test('Clearfolio jobStatus forwards the caller abort signal', async () => { + process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; + const originalFetch = globalThis.fetch; + let observedSignal; + globalThis.fetch = async (_url, options) => { + observedSignal = options.signal; + return { json: async () => ({ status: 'RUNNING' }) }; + }; + try { + const { jobStatus } = await import('../../server/clearfolio.mjs?status-signal-test=1'); + const controller = new AbortController(); + const status = await jobStatus(1, 2, 'job-1', { signal: controller.signal }); + assert.equal(status, 'RUNNING'); + assert.equal(observedSignal, controller.signal); + } finally { + globalThis.fetch = originalFetch; + delete process.env.CLEARFOLIO_URL; + } + }); + '''.strip() + '\n' + Path('tests/unit/clearfolio-status-signal.test.mjs').write_text(signal_test, encoding='utf-8') + + api_test = r'''import test from 'node:test'; + import assert from 'node:assert/strict'; + import { readFileSync } from 'node:fs'; + + process.env.SCOPEWEAVE_DB = ':memory:'; + process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY = '2'; + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS = '500'; + + const { app } = await import('../../server/app.mjs'); + const { db } = await import('../../server/db.mjs'); + + const jsonRequest = (path, options = {}) => app.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, + }); + + async function upload(projectId, token, taskId) { + const form = new FormData(); + form.append('file', new Blob([`content-${taskId}`], { type: 'text/plain' }), `${taskId}.txt`); + form.set('taskId', taskId); + const response = await app.request(`/api/projects/${projectId}/attachments`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: form, + }); + assert.equal(response.status, 200); + return response.json(); + } + + test('attachment listing refreshes without N+1 queries or internal identifier leakage', async () => { + let response = await jsonRequest('/api/auth/signup', { + method: 'POST', + body: JSON.stringify({ email: 'attachments@scopeweave.test', password: 'password123', name: 'Attachments' }), + }); + assert.equal(response.status, 200); + const token = (await response.json()).token; + const auth = { authorization: `Bearer ${token}` }; + + response = await jsonRequest('/api/me', { headers: auth }); + const userId = (await response.json()).user.id; + response = await jsonRequest('/api/projects', { + method: 'POST', + headers: auth, + body: JSON.stringify({ name: 'Attachment Status Project' }), + }); + const projectId = (await response.json()).id; + + const first = await upload(projectId, token, 'task-a'); + const second = await upload(projectId, token, 'task-b'); + db.prepare("UPDATE attachments SET status = 'PENDING' WHERE id IN (?, ?)").run(first.id, second.id); + db.prepare( + 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)', + ).run(projectId, 'task-missing', 'missing.txt', 'text/plain', 1, '', 'PENDING', userId); + + response = await jsonRequest(`/api/projects/${projectId}/attachments?taskId=task-a`, { headers: auth }); + assert.equal(response.status, 200); + let attachments = (await response.json()).attachments; + assert.equal(attachments.length, 1); + assert.equal(attachments[0].taskId, 'task-a'); + assert.equal(attachments[0].status, 'SUCCEEDED'); + assert.equal(Object.hasOwn(attachments[0], 'jobId'), false); + + response = await jsonRequest(`/api/projects/${projectId}/attachments`, { headers: auth }); + assert.equal(response.status, 200); + attachments = (await response.json()).attachments; + assert.equal(attachments.length, 3); + assert.equal(attachments.every((row) => !Object.hasOwn(row, 'jobId')), true); + assert.equal(attachments.find((row) => row.taskId === 'task-b').status, 'SUCCEEDED'); + assert.equal(attachments.find((row) => row.taskId === 'task-missing').status, 'PENDING'); + + response = await jsonRequest('/api/metrics'); + const metrics = await response.json(); + assert.equal(metrics.attachmentStatusRefreshAttempted, 2); + assert.equal(metrics.attachmentStatusRefreshChanged, 2); + assert.equal(metrics.attachmentStatusRefreshFailed, 0); + assert.equal(metrics.attachmentStatusRefreshDeferred, 1); + + const source = readFileSync('server/app.mjs', 'utf8'); + const routeStart = source.indexOf("app.get('/api/projects/:id/attachments'"); + const routeEnd = source.indexOf('// 열람:', routeStart); + const route = source.slice(routeStart, routeEnd); + assert.ok(routeStart >= 0 && routeEnd > routeStart, 'attachment list route is discoverable'); + assert.equal((route.match(/a\.job_id AS jobId/g) || []).length, 2); + assert.doesNotMatch(route, /SELECT job_id FROM attachments/); + assert.match(route, /rows\.map\(\(\{ jobId: _internalJobId, \.\.\.publicRow \}\) => publicRow\)/); + }); + '''.strip() + '\n' + Path('tests/api/attachment-status.test.mjs').write_text(api_test, encoding='utf-8') + + package_path = Path('package.json') + package = json.loads(package_path.read_text(encoding='utf-8')) + package['scripts']['coverage'] = package['scripts']['coverage'].replace( + '--include=server/app.mjs', + '--include=server/attachment_status.mjs --include=server/app.mjs', + ) + package['scripts']['test:api'] += ' && node tests/api/attachment-status.test.mjs' + package['scripts']['test:unit'] += ' && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs' + package['scripts']['test:coverage'] = ( + 'node tests/unit/attachment-status.test.mjs && ' + + package['scripts']['test:coverage'] + + ' && node tests/api/attachment-status.test.mjs' + ) + package_path.write_text(json.dumps(package, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + + replace_once( + 'CHANGELOG.md', + '### Changed\n\n', + '### Changed\n\n- Attachment status refresh now removes the per-row database lookup, uses a configurable bounded worker pool with abortable downstream timeouts, isolates partial failures, excludes internal conversion identifiers from list responses, and exports attempted/changed/failed/deferred operational metrics.\n', + ) + PY + + - name: Install dependencies + run: npm ci + + - name: Run focused and full validation + shell: bash + run: | + set -euo pipefail + node tests/unit/attachment-status.test.mjs + node tests/unit/clearfolio-status-signal.test.mjs + node tests/api/attachment-status.test.mjs + npm run test:unit + npm run test:api + npm run coverage + npm run test:e2e:cloud + git diff --check + + - name: Commit verified repair and remove one-shot workflow + shell: bash + run: | + set -euo pipefail + rm .github/workflows/repair-attachment-status-refresh.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m 'perf(attachments): bound status refresh and remove N+1 queries' + git push origin HEAD:jules-promise-all-attachments-7315426299343398085 From f5ecb726d4de8aba2f72908fa951756ed048cd6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 15:06:56 +0900 Subject: [PATCH 05/45] ci: remove inactive one-shot repair workflow --- .../repair-attachment-status-refresh.yml | 563 ------------------ 1 file changed, 563 deletions(-) delete mode 100644 .github/workflows/repair-attachment-status-refresh.yml diff --git a/.github/workflows/repair-attachment-status-refresh.yml b/.github/workflows/repair-attachment-status-refresh.yml deleted file mode 100644 index fc23557a..00000000 --- a/.github/workflows/repair-attachment-status-refresh.yml +++ /dev/null @@ -1,563 +0,0 @@ -name: Repair attachment status refresh - -on: - push: - branches: - - jules-promise-all-attachments-7315426299343398085 - -permissions: - contents: write - -concurrency: - group: repair-attachment-status-refresh - cancel-in-progress: true - -jobs: - repair: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: jules-promise-all-attachments-7315426299343398085 - fetch-depth: 0 - persist-credentials: true - - - name: Set up Node.js - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - with: - node-version: 22.13.0 - cache: npm - - - name: Apply bounded refresh implementation and regression tests - shell: bash - run: | - set -euo pipefail - python3 <<'PY' - from pathlib import Path - import json - - def replace_once(path, old, new): - file_path = Path(path) - text = file_path.read_text(encoding='utf-8') - count = text.count(old) - if count != 1: - raise SystemExit(f'{path}: expected one replacement target, found {count}') - file_path.write_text(text.replace(old, new, 1), encoding='utf-8') - - attachment_status = r'''/** - * Default maximum number of concurrent Clearfolio status lookups. - * The limit protects the downstream service while keeping list latency bounded. - */ - export const ATTACHMENT_STATUS_DEFAULT_CONCURRENCY = 8; - - /** Conservative hard ceiling for operator-configured status lookup concurrency. */ - export const ATTACHMENT_STATUS_MAX_CONCURRENCY = 32; - - /** Default downstream status lookup timeout in milliseconds. */ - export const ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS = 3_000; - - /** Hard ceiling for the downstream status lookup timeout in milliseconds. */ - export const ATTACHMENT_STATUS_MAX_TIMEOUT_MS = 30_000; - - function normalizeBoundedInteger(value, fallback, maximum) { - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1) return fallback; - return Math.min(parsed, maximum); - } - - /** - * Normalize the configured attachment-status worker count. - * - * @param {unknown} value - Environment or caller supplied value. - * @returns {number} An integer between 1 and 32, defaulting to 8. - */ - export function normalizeAttachmentStatusConcurrency(value) { - return normalizeBoundedInteger( - value, - ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, - ATTACHMENT_STATUS_MAX_CONCURRENCY, - ); - } - - /** - * Normalize the configured Clearfolio status timeout. - * - * @param {unknown} value - Environment or caller supplied value. - * @returns {number} A positive timeout no greater than 30 seconds. - */ - export function normalizeAttachmentStatusTimeoutMs(value) { - return normalizeBoundedInteger( - value, - ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, - ATTACHMENT_STATUS_MAX_TIMEOUT_MS, - ); - } - - function addRefreshMetrics(metrics, counts) { - if (!metrics) return; - const fields = { - attachmentStatusRefreshAttempted: 'attempted', - attachmentStatusRefreshChanged: 'changed', - attachmentStatusRefreshFailed: 'failed', - attachmentStatusRefreshDeferred: 'deferred', - }; - for (const [metric, count] of Object.entries(fields)) { - metrics[metric] = (Number(metrics[metric]) || 0) + counts[count]; - } - } - - /** - * Refresh pending attachment conversion statuses with a bounded worker pool. - * - * Rows are updated in place so the caller can serialize the refreshed public - * representation. Missing job identifiers and downstream failures preserve the - * stale status and never fail the attachment-list response. - * - * @param {Array} rows - Attachment rows containing `id`, `status`, and `jobId`. - * @param {object} options - Downstream functions, tenant identifiers, limits, and metrics. - * @param {number|string} options.orgId - ScopeWeave organization identifier. - * @param {number|string} options.userId - Requesting user identifier. - * @param {(orgId: unknown, userId: unknown, jobId: string, options: {signal: AbortSignal}) => Promise} options.jobStatus - * Downstream status lookup function. - * @param {(status: string, attachmentId: unknown) => unknown|Promise} options.updateStatus - * Persistence callback invoked only when the status changes. - * @param {unknown} [options.concurrency] - Maximum concurrent downstream lookups. - * @param {unknown} [options.timeoutMs] - Per-lookup timeout in milliseconds. - * @param {object} [options.metrics] - Mutable process metrics object. - * @returns {Promise<{attempted:number,changed:number,failed:number,deferred:number}>} - * Structured refresh counters for observability and tests. - */ - export async function refreshAttachmentStatuses(rows, options) { - if (!Array.isArray(rows)) throw new TypeError('rows must be an array'); - if (typeof options?.jobStatus !== 'function') throw new TypeError('jobStatus must be a function'); - if (typeof options?.updateStatus !== 'function') throw new TypeError('updateStatus must be a function'); - - const counts = { attempted: 0, changed: 0, failed: 0, deferred: 0 }; - const pending = rows.filter((row) => row?.status === 'PENDING' || row?.status === 'RUNNING'); - const concurrency = normalizeAttachmentStatusConcurrency(options.concurrency); - const timeoutMs = normalizeAttachmentStatusTimeoutMs(options.timeoutMs); - let cursor = 0; - - async function worker() { - for (;;) { - const index = cursor; - cursor += 1; - if (index >= pending.length) return; - const row = pending[index]; - const jobId = typeof row.jobId === 'string' ? row.jobId.trim() : ''; - if (!jobId) { - counts.deferred += 1; - continue; - } - - counts.attempted += 1; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const nextStatus = await options.jobStatus( - options.orgId, - options.userId, - jobId, - { signal: controller.signal }, - ); - if (typeof nextStatus !== 'string' || !nextStatus.trim()) { - throw new Error('invalid downstream status'); - } - if (nextStatus !== row.status) { - await options.updateStatus(nextStatus, row.id); - row.status = nextStatus; - counts.changed += 1; - } - } catch { - counts.failed += 1; - } finally { - clearTimeout(timer); - } - } - } - - const workerCount = Math.min(concurrency, pending.length); - await Promise.all(Array.from({ length: workerCount }, () => worker())); - addRefreshMetrics(options.metrics, counts); - return counts; - } - '''.strip() + '\n' - Path('server/attachment_status.mjs').write_text(attachment_status, encoding='utf-8') - - replace_once( - 'server/app.mjs', - "import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';\n", - "import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';\nimport { normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs';\n", - ) - - replace_once( - 'server/app.mjs', - "const metrics = { startedAt: new Date().toISOString(), requests: 0, s2xx: 0, s4xx: 0, s5xx: 0, signups: 0, projectsCreated: 0, webhookDeliveries: 0 };", - "const metrics = {\n startedAt: new Date().toISOString(),\n requests: 0,\n s2xx: 0,\n s4xx: 0,\n s5xx: 0,\n signups: 0,\n projectsCreated: 0,\n webhookDeliveries: 0,\n attachmentStatusRefreshAttempted: 0,\n attachmentStatusRefreshChanged: 0,\n attachmentStatusRefreshFailed: 0,\n attachmentStatusRefreshDeferred: 0,\n};", - ) - - replace_once( - 'server/app.mjs', - "const ATTACH_MAX_BYTES = 10 * 1024 * 1024;\n", - "const ATTACH_MAX_BYTES = 10 * 1024 * 1024;\nconst ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency(\n process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY,\n);\nconst ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs(\n process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS,\n);\nconst updateAttachmentStatusStatement = db.prepare(\n 'UPDATE attachments SET status = ? WHERE id = ?',\n);\n", - ) - - old_route = r''' const rows = (taskId - ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) - : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); - // PENDING 잡 상태 갱신(최선 노력). Concurrent, but bounded so a large - // attachment list cannot open unbounded simultaneous Clearfolio calls. - // ponytail: fixed chunk size 5; make it configurable only if rate limits bite. - const pending = rows.filter((r) => r.status === 'PENDING' || r.status === 'RUNNING'); - for (let i = 0; i < pending.length; i += 5) { - await Promise.all(pending.slice(i, i + 5).map(async (r) => { - try { - const jid = db.prepare('SELECT job_id FROM attachments WHERE id = ?').get(r.id).job_id; - const st = await jobStatus(p.org_id, uid, jid); - if (st !== r.status) { - db.prepare('UPDATE attachments SET status = ? WHERE id = ?').run(st, r.id); - r.status = st; - } - } catch { /* keep stale status */ } - })); - } - return c.json({ attachments: rows });''' - new_route = r''' const rows = (taskId - ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) - : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); - await refreshAttachmentStatuses(rows, { - orgId: p.org_id, - userId: uid, - jobStatus, - updateStatus: (status, attachmentId) => updateAttachmentStatusStatement.run(status, attachmentId), - concurrency: ATTACH_STATUS_CONCURRENCY, - timeoutMs: ATTACH_STATUS_TIMEOUT_MS, - metrics, - }); - const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); - return c.json({ attachments });''' - replace_once('server/app.mjs', old_route, new_route) - - old_job_status = r'''export async function jobStatus(orgId, userId, jobId) { - if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; - const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { - headers: tenantHeaders(orgId, userId), - }); - const data = await res.json().catch(() => ({})); - return data.status || 'FAILED'; - }''' - new_job_status = r'''export async function jobStatus(orgId, userId, jobId, { signal } = {}) { - if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; - const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { - headers: tenantHeaders(orgId, userId), - signal, - }); - const data = await res.json().catch(() => ({})); - return data.status || 'FAILED'; - }''' - replace_once('server/clearfolio.mjs', old_job_status, new_job_status) - - unit_test = r'''import test from 'node:test'; - import assert from 'node:assert/strict'; - import { - ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, - ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, - ATTACHMENT_STATUS_MAX_CONCURRENCY, - ATTACHMENT_STATUS_MAX_TIMEOUT_MS, - normalizeAttachmentStatusConcurrency, - normalizeAttachmentStatusTimeoutMs, - refreshAttachmentStatuses, - } from '../../server/attachment_status.mjs'; - - test('attachment status configuration is bounded and fail-safe', () => { - assert.equal(normalizeAttachmentStatusConcurrency(undefined), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); - assert.equal(normalizeAttachmentStatusConcurrency('4'), 4); - assert.equal(normalizeAttachmentStatusConcurrency(0), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); - assert.equal(normalizeAttachmentStatusConcurrency(1.5), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); - assert.equal(normalizeAttachmentStatusConcurrency(999), ATTACHMENT_STATUS_MAX_CONCURRENCY); - assert.equal(normalizeAttachmentStatusTimeoutMs(undefined), ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS); - assert.equal(normalizeAttachmentStatusTimeoutMs('25'), 25); - assert.equal(normalizeAttachmentStatusTimeoutMs(-1), ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS); - assert.equal(normalizeAttachmentStatusTimeoutMs(50_000), ATTACHMENT_STATUS_MAX_TIMEOUT_MS); - }); - - test('refresh validates its dependency contract', async () => { - await assert.rejects(() => refreshAttachmentStatuses(null, {}), /rows must be an array/); - await assert.rejects( - () => refreshAttachmentStatuses([], { updateStatus() {} }), - /jobStatus must be a function/, - ); - await assert.rejects( - () => refreshAttachmentStatuses([], { jobStatus() {} }), - /updateStatus must be a function/, - ); - }); - - test('empty and settled rows perform no downstream work', async () => { - const metrics = {}; - const counts = await refreshAttachmentStatuses( - [{ id: 1, status: 'SUCCEEDED', jobId: 'job-1' }], - { - jobStatus: async () => { throw new Error('must not run'); }, - updateStatus: () => { throw new Error('must not run'); }, - metrics, - }, - ); - assert.deepEqual(counts, { attempted: 0, changed: 0, failed: 0, deferred: 0 }); - assert.equal(metrics.attachmentStatusRefreshAttempted, 0); - assert.equal(metrics.attachmentStatusRefreshChanged, 0); - assert.equal(metrics.attachmentStatusRefreshFailed, 0); - assert.equal(metrics.attachmentStatusRefreshDeferred, 0); - }); - - test('100 pending rows respect the configured concurrency and persist only changes', async () => { - const rows = Array.from({ length: 100 }, (_, index) => ({ - id: index + 1, - jobId: `job-${index + 1}`, - status: index % 3 === 0 ? 'RUNNING' : 'PENDING', - })); - let active = 0; - let peak = 0; - const updates = []; - const metrics = { - attachmentStatusRefreshAttempted: 10, - attachmentStatusRefreshChanged: 20, - attachmentStatusRefreshFailed: 30, - attachmentStatusRefreshDeferred: 40, - }; - const counts = await refreshAttachmentStatuses(rows, { - orgId: 7, - userId: 9, - concurrency: 8, - timeoutMs: 1_000, - metrics, - jobStatus: async (orgId, userId, jobId, { signal }) => { - assert.equal(orgId, 7); - assert.equal(userId, 9); - assert.equal(signal.aborted, false); - active += 1; - peak = Math.max(peak, active); - await new Promise((resolve) => setTimeout(resolve, Number(jobId.split('-')[1]) % 3)); - active -= 1; - return Number(jobId.split('-')[1]) % 2 === 0 ? 'SUCCEEDED' : rows[Number(jobId.split('-')[1]) - 1].status; - }, - updateStatus: async (status, attachmentId) => updates.push([status, attachmentId]), - }); - assert.ok(peak <= 8, `peak concurrency ${peak} exceeded configured limit`); - assert.deepEqual(counts, { attempted: 100, changed: 50, failed: 0, deferred: 0 }); - assert.equal(updates.length, 50); - assert.equal(metrics.attachmentStatusRefreshAttempted, 110); - assert.equal(metrics.attachmentStatusRefreshChanged, 70); - assert.equal(metrics.attachmentStatusRefreshFailed, 30); - assert.equal(metrics.attachmentStatusRefreshDeferred, 40); - }); - - test('invalid identifiers, downstream failures, invalid responses, and write failures remain isolated', async () => { - const rows = [ - { id: 1, jobId: null, status: 'PENDING' }, - { id: 2, jobId: '', status: 'RUNNING' }, - { id: 3, jobId: ' ', status: 'PENDING' }, - { id: 4, jobId: 'throws', status: 'PENDING' }, - { id: 5, jobId: 'invalid-status', status: 'PENDING' }, - { id: 6, jobId: 'write-fails', status: 'PENDING' }, - { id: 7, jobId: 'times-out', status: 'PENDING' }, - ]; - let aborted = false; - const counts = await refreshAttachmentStatuses(rows, { - concurrency: 3, - timeoutMs: 5, - jobStatus: async (_orgId, _userId, jobId, { signal }) => { - if (jobId === 'throws') throw new Error('downstream failure'); - if (jobId === 'invalid-status') return ''; - if (jobId === 'write-fails') return 'SUCCEEDED'; - return new Promise((resolve, reject) => { - signal.addEventListener('abort', () => { - aborted = true; - reject(new Error('aborted')); - }, { once: true }); - }); - }, - updateStatus: () => { throw new Error('write failure'); }, - }); - assert.equal(aborted, true); - assert.deepEqual(counts, { attempted: 4, changed: 0, failed: 4, deferred: 3 }); - assert.equal(rows[5].status, 'PENDING'); - assert.equal(rows[6].status, 'PENDING'); - }); - '''.strip() + '\n' - Path('tests/unit/attachment-status.test.mjs').write_text(unit_test, encoding='utf-8') - - signal_test = r'''import test from 'node:test'; - import assert from 'node:assert/strict'; - - test('Clearfolio jobStatus forwards the caller abort signal', async () => { - process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; - const originalFetch = globalThis.fetch; - let observedSignal; - globalThis.fetch = async (_url, options) => { - observedSignal = options.signal; - return { json: async () => ({ status: 'RUNNING' }) }; - }; - try { - const { jobStatus } = await import('../../server/clearfolio.mjs?status-signal-test=1'); - const controller = new AbortController(); - const status = await jobStatus(1, 2, 'job-1', { signal: controller.signal }); - assert.equal(status, 'RUNNING'); - assert.equal(observedSignal, controller.signal); - } finally { - globalThis.fetch = originalFetch; - delete process.env.CLEARFOLIO_URL; - } - }); - '''.strip() + '\n' - Path('tests/unit/clearfolio-status-signal.test.mjs').write_text(signal_test, encoding='utf-8') - - api_test = r'''import test from 'node:test'; - import assert from 'node:assert/strict'; - import { readFileSync } from 'node:fs'; - - process.env.SCOPEWEAVE_DB = ':memory:'; - process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY = '2'; - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS = '500'; - - const { app } = await import('../../server/app.mjs'); - const { db } = await import('../../server/db.mjs'); - - const jsonRequest = (path, options = {}) => app.request(path, { - ...options, - headers: { 'content-type': 'application/json', ...(options.headers || {}) }, - }); - - async function upload(projectId, token, taskId) { - const form = new FormData(); - form.append('file', new Blob([`content-${taskId}`], { type: 'text/plain' }), `${taskId}.txt`); - form.set('taskId', taskId); - const response = await app.request(`/api/projects/${projectId}/attachments`, { - method: 'POST', - headers: { authorization: `Bearer ${token}` }, - body: form, - }); - assert.equal(response.status, 200); - return response.json(); - } - - test('attachment listing refreshes without N+1 queries or internal identifier leakage', async () => { - let response = await jsonRequest('/api/auth/signup', { - method: 'POST', - body: JSON.stringify({ email: 'attachments@scopeweave.test', password: 'password123', name: 'Attachments' }), - }); - assert.equal(response.status, 200); - const token = (await response.json()).token; - const auth = { authorization: `Bearer ${token}` }; - - response = await jsonRequest('/api/me', { headers: auth }); - const userId = (await response.json()).user.id; - response = await jsonRequest('/api/projects', { - method: 'POST', - headers: auth, - body: JSON.stringify({ name: 'Attachment Status Project' }), - }); - const projectId = (await response.json()).id; - - const first = await upload(projectId, token, 'task-a'); - const second = await upload(projectId, token, 'task-b'); - db.prepare("UPDATE attachments SET status = 'PENDING' WHERE id IN (?, ?)").run(first.id, second.id); - db.prepare( - 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)', - ).run(projectId, 'task-missing', 'missing.txt', 'text/plain', 1, '', 'PENDING', userId); - - response = await jsonRequest(`/api/projects/${projectId}/attachments?taskId=task-a`, { headers: auth }); - assert.equal(response.status, 200); - let attachments = (await response.json()).attachments; - assert.equal(attachments.length, 1); - assert.equal(attachments[0].taskId, 'task-a'); - assert.equal(attachments[0].status, 'SUCCEEDED'); - assert.equal(Object.hasOwn(attachments[0], 'jobId'), false); - - response = await jsonRequest(`/api/projects/${projectId}/attachments`, { headers: auth }); - assert.equal(response.status, 200); - attachments = (await response.json()).attachments; - assert.equal(attachments.length, 3); - assert.equal(attachments.every((row) => !Object.hasOwn(row, 'jobId')), true); - assert.equal(attachments.find((row) => row.taskId === 'task-b').status, 'SUCCEEDED'); - assert.equal(attachments.find((row) => row.taskId === 'task-missing').status, 'PENDING'); - - response = await jsonRequest('/api/metrics'); - const metrics = await response.json(); - assert.equal(metrics.attachmentStatusRefreshAttempted, 2); - assert.equal(metrics.attachmentStatusRefreshChanged, 2); - assert.equal(metrics.attachmentStatusRefreshFailed, 0); - assert.equal(metrics.attachmentStatusRefreshDeferred, 1); - - const source = readFileSync('server/app.mjs', 'utf8'); - const routeStart = source.indexOf("app.get('/api/projects/:id/attachments'"); - const routeEnd = source.indexOf('// 열람:', routeStart); - const route = source.slice(routeStart, routeEnd); - assert.ok(routeStart >= 0 && routeEnd > routeStart, 'attachment list route is discoverable'); - assert.equal((route.match(/a\.job_id AS jobId/g) || []).length, 2); - assert.doesNotMatch(route, /SELECT job_id FROM attachments/); - assert.match(route, /rows\.map\(\(\{ jobId: _internalJobId, \.\.\.publicRow \}\) => publicRow\)/); - }); - '''.strip() + '\n' - Path('tests/api/attachment-status.test.mjs').write_text(api_test, encoding='utf-8') - - package_path = Path('package.json') - package = json.loads(package_path.read_text(encoding='utf-8')) - package['scripts']['coverage'] = package['scripts']['coverage'].replace( - '--include=server/app.mjs', - '--include=server/attachment_status.mjs --include=server/app.mjs', - ) - package['scripts']['test:api'] += ' && node tests/api/attachment-status.test.mjs' - package['scripts']['test:unit'] += ' && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs' - package['scripts']['test:coverage'] = ( - 'node tests/unit/attachment-status.test.mjs && ' - + package['scripts']['test:coverage'] - + ' && node tests/api/attachment-status.test.mjs' - ) - package_path.write_text(json.dumps(package, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') - - replace_once( - 'CHANGELOG.md', - '### Changed\n\n', - '### Changed\n\n- Attachment status refresh now removes the per-row database lookup, uses a configurable bounded worker pool with abortable downstream timeouts, isolates partial failures, excludes internal conversion identifiers from list responses, and exports attempted/changed/failed/deferred operational metrics.\n', - ) - PY - - - name: Install dependencies - run: npm ci - - - name: Run focused and full validation - shell: bash - run: | - set -euo pipefail - node tests/unit/attachment-status.test.mjs - node tests/unit/clearfolio-status-signal.test.mjs - node tests/api/attachment-status.test.mjs - npm run test:unit - npm run test:api - npm run coverage - npm run test:e2e:cloud - git diff --check - - - name: Commit verified repair and remove one-shot workflow - shell: bash - run: | - set -euo pipefail - rm .github/workflows/repair-attachment-status-refresh.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git commit -m 'perf(attachments): bound status refresh and remove N+1 queries' - git push origin HEAD:jules-promise-all-attachments-7315426299343398085 From 63a8f18b305b33b7a8c1b12584aa28aff448990b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 15:21:26 +0900 Subject: [PATCH 06/45] ci: validate and apply attachment refresh hardening --- .../repair-attachment-status-refresh.yml | 575 ++++++++++++++++++ 1 file changed, 575 insertions(+) create mode 100644 .github/workflows/repair-attachment-status-refresh.yml diff --git a/.github/workflows/repair-attachment-status-refresh.yml b/.github/workflows/repair-attachment-status-refresh.yml new file mode 100644 index 00000000..7e7c56d9 --- /dev/null +++ b/.github/workflows/repair-attachment-status-refresh.yml @@ -0,0 +1,575 @@ +name: Repair attachment status refresh + +on: + pull_request: + branches: [develop] + types: [ready_for_review] + +permissions: + contents: write + +concurrency: + group: repair-attachment-status-refresh-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + repair: + if: github.event.pull_request.number == 420 && github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: jules-promise-all-attachments-7315426299343398085 + fetch-depth: 0 + persist-credentials: true + + - name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 22.13.0 + cache: npm + + - name: Apply bounded refresh implementation and regression tests + shell: bash + run: | + set -euo pipefail + python3 <<'PY' + from pathlib import Path + import json + + def replace_once(path, old, new): + file_path = Path(path) + text = file_path.read_text(encoding='utf-8') + count = text.count(old) + if count != 1: + raise SystemExit(f'{path}: expected one replacement target, found {count}') + file_path.write_text(text.replace(old, new, 1), encoding='utf-8') + + attachment_status = r'''/** Default maximum concurrent Clearfolio status lookups. */ + export const ATTACHMENT_STATUS_DEFAULT_CONCURRENCY = 8; + + /** Conservative hard ceiling for operator-configured lookup concurrency. */ + export const ATTACHMENT_STATUS_MAX_CONCURRENCY = 32; + + /** Default downstream status lookup timeout in milliseconds. */ + export const ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS = 3_000; + + /** Hard ceiling for a downstream status lookup timeout in milliseconds. */ + export const ATTACHMENT_STATUS_MAX_TIMEOUT_MS = 30_000; + + /** + * Normalize a positive integer while applying a conservative upper bound. + * + * @param {unknown} value - Untrusted environment or caller value. + * @param {number} fallback - Value used for missing or invalid input. + * @param {number} maximum - Largest accepted value. + * @returns {number} A safe positive integer no greater than `maximum`. + */ + function normalizeBoundedInteger(value, fallback, maximum) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) return fallback; + return Math.min(parsed, maximum); + } + + /** + * Normalize the configured attachment-status worker count. + * + * @param {unknown} value - Environment or caller supplied value. + * @returns {number} An integer between 1 and 32, defaulting to 8. + */ + export function normalizeAttachmentStatusConcurrency(value) { + return normalizeBoundedInteger( + value, + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ATTACHMENT_STATUS_MAX_CONCURRENCY, + ); + } + + /** + * Normalize the configured Clearfolio status timeout. + * + * @param {unknown} value - Environment or caller supplied value. + * @returns {number} A positive timeout no greater than 30 seconds. + */ + export function normalizeAttachmentStatusTimeoutMs(value) { + return normalizeBoundedInteger( + value, + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ATTACHMENT_STATUS_MAX_TIMEOUT_MS, + ); + } + + /** + * Add one refresh result to process-level operational counters. + * + * @param {object|undefined} metrics - Mutable process metric registry. + * @param {{attempted:number,changed:number,failed:number,deferred:number}} counts - Refresh result. + * @returns {void} + */ + function addRefreshMetrics(metrics, counts) { + if (!metrics) return; + const fields = { + attachmentStatusRefreshAttempted: 'attempted', + attachmentStatusRefreshChanged: 'changed', + attachmentStatusRefreshFailed: 'failed', + attachmentStatusRefreshDeferred: 'deferred', + }; + for (const [metric, count] of Object.entries(fields)) { + metrics[metric] = (Number(metrics[metric]) || 0) + counts[count]; + } + } + + /** + * Refresh pending attachment statuses through a bounded worker pool. + * + * Rows are updated in place so the caller can serialize the refreshed public + * representation. Missing job identifiers and downstream or persistence + * failures preserve stale status and never fail the attachment-list response. + * + * @param {Array} rows - Attachment rows containing `id`, `status`, and `jobId`. + * @param {object} options - Downstream functions, tenant identifiers, limits, and metrics. + * @param {number|string} options.orgId - ScopeWeave organization identifier. + * @param {number|string} options.userId - Requesting user identifier. + * @param {(orgId: unknown, userId: unknown, jobId: string, options: {signal: AbortSignal}) => Promise} options.jobStatus - Downstream lookup. + * @param {(status: string, attachmentId: unknown) => unknown|Promise} options.updateStatus - Changed-only persistence callback. + * @param {unknown} [options.concurrency] - Maximum concurrent lookups. + * @param {unknown} [options.timeoutMs] - Per-lookup timeout in milliseconds. + * @param {object} [options.metrics] - Mutable process metrics object. + * @returns {Promise<{attempted:number,changed:number,failed:number,deferred:number}>} Structured counters. + */ + export async function refreshAttachmentStatuses(rows, options) { + if (!Array.isArray(rows)) throw new TypeError('rows must be an array'); + if (typeof options?.jobStatus !== 'function') throw new TypeError('jobStatus must be a function'); + if (typeof options?.updateStatus !== 'function') throw new TypeError('updateStatus must be a function'); + + const counts = { attempted: 0, changed: 0, failed: 0, deferred: 0 }; + const pending = rows.filter((row) => row?.status === 'PENDING' || row?.status === 'RUNNING'); + const concurrency = normalizeAttachmentStatusConcurrency(options.concurrency); + const timeoutMs = normalizeAttachmentStatusTimeoutMs(options.timeoutMs); + let cursor = 0; + + async function worker() { + for (;;) { + const index = cursor; + cursor += 1; + if (index >= pending.length) return; + const row = pending[index]; + const jobId = typeof row.jobId === 'string' ? row.jobId.trim() : ''; + if (!jobId) { + counts.deferred += 1; + continue; + } + + counts.attempted += 1; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const nextStatus = await options.jobStatus( + options.orgId, + options.userId, + jobId, + { signal: controller.signal }, + ); + if (typeof nextStatus !== 'string' || !nextStatus.trim()) { + throw new Error('invalid downstream status'); + } + if (nextStatus !== row.status) { + await options.updateStatus(nextStatus, row.id); + row.status = nextStatus; + counts.changed += 1; + } + } catch { + counts.failed += 1; + } finally { + clearTimeout(timer); + } + } + } + + const workerCount = Math.min(concurrency, pending.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + addRefreshMetrics(options.metrics, counts); + return counts; + } + '''.strip() + '\n' + Path('server/attachment_status.mjs').write_text(attachment_status, encoding='utf-8') + + replace_once( + 'server/app.mjs', + "import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';\n", + "import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';\nimport { normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs';\n", + ) + + replace_once( + 'server/app.mjs', + "const metrics = { startedAt: new Date().toISOString(), requests: 0, s2xx: 0, s4xx: 0, s5xx: 0, signups: 0, projectsCreated: 0, webhookDeliveries: 0 };", + "const metrics = {\n startedAt: new Date().toISOString(),\n requests: 0,\n s2xx: 0,\n s4xx: 0,\n s5xx: 0,\n signups: 0,\n projectsCreated: 0,\n webhookDeliveries: 0,\n attachmentStatusRefreshAttempted: 0,\n attachmentStatusRefreshChanged: 0,\n attachmentStatusRefreshFailed: 0,\n attachmentStatusRefreshDeferred: 0,\n};", + ) + + replace_once( + 'server/app.mjs', + "const ATTACH_MAX_BYTES = 10 * 1024 * 1024;\n", + "const ATTACH_MAX_BYTES = 10 * 1024 * 1024;\nconst ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency(\n process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY,\n);\nconst ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs(\n process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS,\n);\nconst updateAttachmentStatusStatement = db.prepare(\n 'UPDATE attachments SET status = ? WHERE id = ?',\n);\n", + ) + + old_route = r''' const rows = (taskId + ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) + : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); + // PENDING 잡 상태 갱신(최선 노력). Concurrent, but bounded so a large + // attachment list cannot open unbounded simultaneous Clearfolio calls. + // ponytail: fixed chunk size 5; make it configurable only if rate limits bite. + const pending = rows.filter((r) => r.status === 'PENDING' || r.status === 'RUNNING'); + for (let i = 0; i < pending.length; i += 5) { + await Promise.all(pending.slice(i, i + 5).map(async (r) => { + try { + const jid = db.prepare('SELECT job_id FROM attachments WHERE id = ?').get(r.id).job_id; + const st = await jobStatus(p.org_id, uid, jid); + if (st !== r.status) { + db.prepare('UPDATE attachments SET status = ? WHERE id = ?').run(st, r.id); + r.status = st; + } + } catch { /* keep stale status */ } + })); + } + return c.json({ attachments: rows });''' + new_route = r''' const rows = (taskId + ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) + : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); + await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + metrics, + }); + const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); + return c.json({ attachments });''' + replace_once('server/app.mjs', old_route, new_route) + + old_job_status = r'''export async function jobStatus(orgId, userId, jobId) { + if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; + const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { + headers: tenantHeaders(orgId, userId), + }); + const data = await res.json().catch(() => ({})); + return data.status || 'FAILED'; + }''' + new_job_status = r'''/** + * Read a Clearfolio conversion status, optionally using a caller-owned abort signal. + * + * @param {number|string} orgId - ScopeWeave organization identifier. + * @param {number|string} userId - Requesting user identifier. + * @param {string} jobId - Clearfolio conversion job identifier. + * @param {{signal?: AbortSignal}} [options] - Optional request cancellation signal. + * @returns {Promise} Downstream conversion status. + */ + export async function jobStatus(orgId, userId, jobId, { signal } = {}) { + if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; + const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { + headers: tenantHeaders(orgId, userId), + signal, + }); + const data = await res.json().catch(() => ({})); + return data.status || 'FAILED'; + }''' + replace_once('server/clearfolio.mjs', old_job_status, new_job_status) + + unit_test = r'''import test from 'node:test'; + import assert from 'node:assert/strict'; + import { + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ATTACHMENT_STATUS_MAX_CONCURRENCY, + ATTACHMENT_STATUS_MAX_TIMEOUT_MS, + normalizeAttachmentStatusConcurrency, + normalizeAttachmentStatusTimeoutMs, + refreshAttachmentStatuses, + } from '../../server/attachment_status.mjs'; + + test('attachment status configuration is bounded and fail-safe', () => { + assert.equal(normalizeAttachmentStatusConcurrency(undefined), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); + assert.equal(normalizeAttachmentStatusConcurrency('4'), 4); + assert.equal(normalizeAttachmentStatusConcurrency(0), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); + assert.equal(normalizeAttachmentStatusConcurrency(1.5), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); + assert.equal(normalizeAttachmentStatusConcurrency(999), ATTACHMENT_STATUS_MAX_CONCURRENCY); + assert.equal(normalizeAttachmentStatusTimeoutMs(undefined), ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS); + assert.equal(normalizeAttachmentStatusTimeoutMs('25'), 25); + assert.equal(normalizeAttachmentStatusTimeoutMs(-1), ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS); + assert.equal(normalizeAttachmentStatusTimeoutMs(50_000), ATTACHMENT_STATUS_MAX_TIMEOUT_MS); + }); + + test('refresh validates its dependency contract', async () => { + await assert.rejects(() => refreshAttachmentStatuses(null, {}), /rows must be an array/); + await assert.rejects(() => refreshAttachmentStatuses([], { updateStatus() {} }), /jobStatus must be a function/); + await assert.rejects(() => refreshAttachmentStatuses([], { jobStatus() {} }), /updateStatus must be a function/); + }); + + test('empty and settled rows perform no downstream work', async () => { + const metrics = {}; + const counts = await refreshAttachmentStatuses( + [{ id: 1, status: 'SUCCEEDED', jobId: 'job-1' }], + { + jobStatus: async () => { throw new Error('must not run'); }, + updateStatus: () => { throw new Error('must not run'); }, + metrics, + }, + ); + assert.deepEqual(counts, { attempted: 0, changed: 0, failed: 0, deferred: 0 }); + assert.equal(metrics.attachmentStatusRefreshAttempted, 0); + assert.equal(metrics.attachmentStatusRefreshChanged, 0); + assert.equal(metrics.attachmentStatusRefreshFailed, 0); + assert.equal(metrics.attachmentStatusRefreshDeferred, 0); + }); + + test('100 pending rows respect configured concurrency and persist only changes', async () => { + const rows = Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + jobId: `job-${index + 1}`, + status: index % 3 === 0 ? 'RUNNING' : 'PENDING', + })); + let active = 0; + let peak = 0; + const updates = []; + const metrics = { + attachmentStatusRefreshAttempted: 10, + attachmentStatusRefreshChanged: 20, + attachmentStatusRefreshFailed: 30, + attachmentStatusRefreshDeferred: 40, + }; + const counts = await refreshAttachmentStatuses(rows, { + orgId: 7, + userId: 9, + concurrency: 8, + timeoutMs: 1_000, + metrics, + jobStatus: async (orgId, userId, jobId, { signal }) => { + assert.equal(orgId, 7); + assert.equal(userId, 9); + assert.equal(signal.aborted, false); + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, Number(jobId.split('-')[1]) % 3)); + active -= 1; + const row = rows[Number(jobId.split('-')[1]) - 1]; + return Number(jobId.split('-')[1]) % 2 === 0 ? 'SUCCEEDED' : row.status; + }, + updateStatus: async (status, attachmentId) => updates.push([status, attachmentId]), + }); + assert.ok(peak <= 8, `peak concurrency ${peak} exceeded configured limit`); + assert.deepEqual(counts, { attempted: 100, changed: 50, failed: 0, deferred: 0 }); + assert.equal(updates.length, 50); + assert.equal(metrics.attachmentStatusRefreshAttempted, 110); + assert.equal(metrics.attachmentStatusRefreshChanged, 70); + assert.equal(metrics.attachmentStatusRefreshFailed, 30); + assert.equal(metrics.attachmentStatusRefreshDeferred, 40); + }); + + test('invalid identifiers and downstream, timeout, or write failures remain isolated', async () => { + const rows = [ + { id: 1, jobId: null, status: 'PENDING' }, + { id: 2, jobId: '', status: 'RUNNING' }, + { id: 3, jobId: ' ', status: 'PENDING' }, + { id: 4, jobId: 'throws', status: 'PENDING' }, + { id: 5, jobId: 'invalid-status', status: 'PENDING' }, + { id: 6, jobId: 'write-fails', status: 'PENDING' }, + { id: 7, jobId: 'times-out', status: 'PENDING' }, + ]; + let aborted = false; + const counts = await refreshAttachmentStatuses(rows, { + concurrency: 3, + timeoutMs: 5, + jobStatus: async (_orgId, _userId, jobId, { signal }) => { + if (jobId === 'throws') throw new Error('downstream failure'); + if (jobId === 'invalid-status') return ''; + if (jobId === 'write-fails') return 'SUCCEEDED'; + return new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + aborted = true; + reject(new Error('aborted')); + }, { once: true }); + }); + }, + updateStatus: () => { throw new Error('write failure'); }, + }); + assert.equal(aborted, true); + assert.deepEqual(counts, { attempted: 4, changed: 0, failed: 4, deferred: 3 }); + assert.equal(rows[5].status, 'PENDING'); + assert.equal(rows[6].status, 'PENDING'); + }); + '''.strip() + '\n' + Path('tests/unit/attachment-status.test.mjs').write_text(unit_test, encoding='utf-8') + + signal_test = r'''import test from 'node:test'; + import assert from 'node:assert/strict'; + + test('Clearfolio jobStatus forwards the caller abort signal', async () => { + process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; + const originalFetch = globalThis.fetch; + let observedSignal; + globalThis.fetch = async (_url, options) => { + observedSignal = options.signal; + return { json: async () => ({ status: 'RUNNING' }) }; + }; + try { + const { jobStatus } = await import('../../server/clearfolio.mjs?status-signal-test=1'); + const controller = new AbortController(); + const status = await jobStatus(1, 2, 'job-1', { signal: controller.signal }); + assert.equal(status, 'RUNNING'); + assert.equal(observedSignal, controller.signal); + } finally { + globalThis.fetch = originalFetch; + delete process.env.CLEARFOLIO_URL; + } + }); + '''.strip() + '\n' + Path('tests/unit/clearfolio-status-signal.test.mjs').write_text(signal_test, encoding='utf-8') + + api_test = r'''import test from 'node:test'; + import assert from 'node:assert/strict'; + import { readFileSync } from 'node:fs'; + + process.env.SCOPEWEAVE_DB = ':memory:'; + process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY = '2'; + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS = '500'; + + const { app } = await import('../../server/app.mjs'); + const { db } = await import('../../server/db.mjs'); + + const jsonRequest = (path, options = {}) => app.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, + }); + + async function upload(projectId, token, taskId) { + const form = new FormData(); + form.append('file', new Blob([`content-${taskId}`], { type: 'text/plain' }), `${taskId}.txt`); + form.set('taskId', taskId); + const response = await app.request(`/api/projects/${projectId}/attachments`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: form, + }); + assert.equal(response.status, 200); + return response.json(); + } + + test('attachment listing refreshes without N+1 queries or internal identifier leakage', async () => { + let response = await jsonRequest('/api/auth/signup', { + method: 'POST', + body: JSON.stringify({ email: 'attachments@scopeweave.test', password: 'password123', name: 'Attachments' }), + }); + assert.equal(response.status, 200); + const token = (await response.json()).token; + const auth = { authorization: `Bearer ${token}` }; + + response = await jsonRequest('/api/me', { headers: auth }); + const userId = (await response.json()).user.id; + response = await jsonRequest('/api/projects', { + method: 'POST', + headers: auth, + body: JSON.stringify({ name: 'Attachment Status Project' }), + }); + const projectId = (await response.json()).id; + + const first = await upload(projectId, token, 'task-a'); + const second = await upload(projectId, token, 'task-b'); + db.prepare("UPDATE attachments SET status = 'PENDING' WHERE id IN (?, ?)").run(first.id, second.id); + db.prepare('INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)') + .run(projectId, 'task-missing', 'missing.txt', 'text/plain', 1, '', 'PENDING', userId); + + response = await jsonRequest(`/api/projects/${projectId}/attachments?taskId=task-a`, { headers: auth }); + assert.equal(response.status, 200); + let attachments = (await response.json()).attachments; + assert.equal(attachments.length, 1); + assert.equal(attachments[0].taskId, 'task-a'); + assert.equal(attachments[0].status, 'SUCCEEDED'); + assert.equal(Object.hasOwn(attachments[0], 'jobId'), false); + + response = await jsonRequest(`/api/projects/${projectId}/attachments`, { headers: auth }); + assert.equal(response.status, 200); + attachments = (await response.json()).attachments; + assert.equal(attachments.length, 3); + assert.equal(attachments.every((row) => !Object.hasOwn(row, 'jobId')), true); + assert.equal(attachments.find((row) => row.taskId === 'task-b').status, 'SUCCEEDED'); + assert.equal(attachments.find((row) => row.taskId === 'task-missing').status, 'PENDING'); + + response = await jsonRequest('/api/metrics'); + const metrics = await response.json(); + assert.equal(metrics.attachmentStatusRefreshAttempted, 2); + assert.equal(metrics.attachmentStatusRefreshChanged, 2); + assert.equal(metrics.attachmentStatusRefreshFailed, 0); + assert.equal(metrics.attachmentStatusRefreshDeferred, 1); + + const source = readFileSync('server/app.mjs', 'utf8'); + const routeStart = source.indexOf("app.get('/api/projects/:id/attachments'"); + const routeEnd = source.indexOf('// 열람:', routeStart); + const route = source.slice(routeStart, routeEnd); + assert.ok(routeStart >= 0 && routeEnd > routeStart, 'attachment list route is discoverable'); + assert.equal((route.match(/a\.job_id AS jobId/g) || []).length, 2); + assert.doesNotMatch(route, /SELECT job_id FROM attachments/); + assert.match(route, /rows\.map\(\(\{ jobId: _internalJobId, \.\.\.publicRow \}\) => publicRow\)/); + }); + '''.strip() + '\n' + Path('tests/api/attachment-status.test.mjs').write_text(api_test, encoding='utf-8') + + package_path = Path('package.json') + package = json.loads(package_path.read_text(encoding='utf-8')) + package['scripts']['coverage'] = package['scripts']['coverage'].replace( + '--include=server/app.mjs', + '--include=server/attachment_status.mjs --include=server/app.mjs', + ) + package['scripts']['test:api'] += ' && node tests/api/attachment-status.test.mjs' + package['scripts']['test:unit'] += ' && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs' + package['scripts']['test:coverage'] = ( + 'node tests/unit/attachment-status.test.mjs && ' + + package['scripts']['test:coverage'] + + ' && node tests/api/attachment-status.test.mjs' + ) + package_path.write_text(json.dumps(package, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + + replace_once( + 'CHANGELOG.md', + '### Changed\n\n', + '### Changed\n\n- Attachment status refresh now removes the per-row database lookup, uses a configurable bounded worker pool with abortable downstream timeouts, isolates partial failures, excludes internal conversion identifiers from list responses, and exports attempted/changed/failed/deferred operational metrics.\n', + ) + PY + + - name: Install dependencies + run: npm ci + + - name: Run focused and full validation + shell: bash + run: | + set -euo pipefail + node tests/unit/attachment-status.test.mjs + node tests/unit/clearfolio-status-signal.test.mjs + node tests/api/attachment-status.test.mjs + npm run test:unit + npm run test:api + npm run coverage + npm run test:e2e:cloud + git diff --check + + - name: Commit verified repair and remove one-shot workflow + shell: bash + run: | + set -euo pipefail + rm .github/workflows/repair-attachment-status-refresh.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m 'perf(attachments): bound status refresh and remove N+1 queries' + git push origin HEAD:jules-promise-all-attachments-7315426299343398085 From bcdb27fbf70ba9581f3c1c3ef12ee86088622bf4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 15:44:13 +0900 Subject: [PATCH 07/45] perf(attachments): add bounded status refresh engine --- server/attachment_status.mjs | 175 +++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 server/attachment_status.mjs diff --git a/server/attachment_status.mjs b/server/attachment_status.mjs new file mode 100644 index 00000000..398f9520 --- /dev/null +++ b/server/attachment_status.mjs @@ -0,0 +1,175 @@ +/** Default maximum concurrent Clearfolio status lookups. */ +export const ATTACHMENT_STATUS_DEFAULT_CONCURRENCY = 8; + +/** Conservative hard ceiling for operator-configured lookup concurrency. */ +export const ATTACHMENT_STATUS_MAX_CONCURRENCY = 32; + +/** Default downstream status lookup timeout in milliseconds. */ +export const ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS = 3_000; + +/** Hard ceiling for a downstream status lookup timeout in milliseconds. */ +export const ATTACHMENT_STATUS_MAX_TIMEOUT_MS = 30_000; + +const ATTACHMENT_STATUS_VALUES = new Set(['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED']); + +/** + * Normalize a positive integer while applying a conservative upper bound. + * + * @param {unknown} value - Untrusted environment or caller value. + * @param {number} fallback - Value used for missing or invalid input. + * @param {number} maximum - Largest accepted value. + * @returns {number} A safe positive integer no greater than `maximum`. + */ +function normalizeBoundedInteger(value, fallback, maximum) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) return fallback; + return Math.min(parsed, maximum); +} + +/** + * Normalize the configured attachment-status worker count. + * + * @param {unknown} value - Environment or caller supplied value. + * @returns {number} An integer between 1 and 32, defaulting to 8. + */ +export function normalizeAttachmentStatusConcurrency(value) { + return normalizeBoundedInteger( + value, + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ATTACHMENT_STATUS_MAX_CONCURRENCY, + ); +} + +/** + * Normalize the configured Clearfolio status timeout. + * + * @param {unknown} value - Environment or caller supplied value. + * @returns {number} A positive timeout no greater than 30 seconds. + */ +export function normalizeAttachmentStatusTimeoutMs(value) { + return normalizeBoundedInteger( + value, + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ATTACHMENT_STATUS_MAX_TIMEOUT_MS, + ); +} + +/** + * Add one refresh result to process-level operational counters. + * + * @param {object|undefined} metrics - Mutable process metric registry. + * @param {{attempted:number,changed:number,failed:number,deferred:number}} counts - Refresh result. + * @returns {void} + */ +function addRefreshMetrics(metrics, counts) { + if (!metrics) return; + const fields = { + attachmentStatusRefreshAttempted: 'attempted', + attachmentStatusRefreshChanged: 'changed', + attachmentStatusRefreshFailed: 'failed', + attachmentStatusRefreshDeferred: 'deferred', + }; + for (const [metric, count] of Object.entries(fields)) { + metrics[metric] = (Number(metrics[metric]) || 0) + counts[count]; + } +} + +/** + * Await one downstream lookup with an AbortSignal and a hard caller-side timeout. + * + * The explicit race means a non-compliant downstream adapter cannot hold a list + * response open forever even if it ignores the supplied AbortSignal. + * + * @param {() => Promise} lookup - Deferred downstream lookup. + * @param {AbortController} controller - Controller whose signal is passed downstream. + * @param {number} timeoutMs - Hard timeout in milliseconds. + * @returns {Promise} The downstream status. + */ +async function withTimeout(lookup, controller, timeoutMs) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new Error('attachment status lookup timed out')); + }, timeoutMs); + }); + try { + return await Promise.race([lookup(), timeout]); + } finally { + clearTimeout(timer); + } +} + +/** + * Refresh pending attachment statuses through a bounded worker pool. + * + * Rows are updated in place so the caller can serialize the refreshed public + * representation. Missing job identifiers and downstream or persistence + * failures preserve stale status and never fail the attachment-list response. + * + * @param {Array} rows - Attachment rows containing `id`, `status`, and `jobId`. + * @param {object} options - Downstream functions, tenant identifiers, limits, and metrics. + * @param {number|string} options.orgId - ScopeWeave organization identifier. + * @param {number|string} options.userId - Requesting user identifier. + * @param {(orgId: unknown, userId: unknown, jobId: string, options: {signal: AbortSignal}) => Promise} options.jobStatus - Downstream lookup. + * @param {(status: string, attachmentId: unknown) => unknown|Promise} options.updateStatus - Changed-only persistence callback. + * @param {unknown} [options.concurrency] - Maximum concurrent lookups. + * @param {unknown} [options.timeoutMs] - Per-lookup timeout in milliseconds. + * @param {object} [options.metrics] - Mutable process metrics object. + * @returns {Promise<{attempted:number,changed:number,failed:number,deferred:number}>} Structured counters. + */ +export async function refreshAttachmentStatuses(rows, options) { + if (!Array.isArray(rows)) throw new TypeError('rows must be an array'); + if (typeof options?.jobStatus !== 'function') throw new TypeError('jobStatus must be a function'); + if (typeof options?.updateStatus !== 'function') throw new TypeError('updateStatus must be a function'); + + const counts = { attempted: 0, changed: 0, failed: 0, deferred: 0 }; + const pending = rows.filter((row) => row?.status === 'PENDING' || row?.status === 'RUNNING'); + const concurrency = normalizeAttachmentStatusConcurrency(options.concurrency); + const timeoutMs = normalizeAttachmentStatusTimeoutMs(options.timeoutMs); + let cursor = 0; + + async function worker() { + for (;;) { + const index = cursor; + cursor += 1; + if (index >= pending.length) return; + const row = pending[index]; + const jobId = typeof row.jobId === 'string' ? row.jobId.trim() : ''; + if (!jobId) { + counts.deferred += 1; + continue; + } + + counts.attempted += 1; + const controller = new AbortController(); + try { + const nextStatus = await withTimeout( + () => options.jobStatus( + options.orgId, + options.userId, + jobId, + { signal: controller.signal }, + ), + controller, + timeoutMs, + ); + if (!ATTACHMENT_STATUS_VALUES.has(nextStatus)) { + throw new Error('invalid downstream status'); + } + if (nextStatus !== row.status) { + await options.updateStatus(nextStatus, row.id); + row.status = nextStatus; + counts.changed += 1; + } + } catch { + counts.failed += 1; + } + } + } + + const workerCount = Math.min(concurrency, pending.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + addRefreshMetrics(options.metrics, counts); + return counts; +} From d705f23804630fa7b8ecceebee9265fe78e83599 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 15:44:57 +0900 Subject: [PATCH 08/45] test(attachments): cover bounded refresh engine --- tests/unit/attachment-status.test.mjs | 161 ++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 tests/unit/attachment-status.test.mjs diff --git a/tests/unit/attachment-status.test.mjs b/tests/unit/attachment-status.test.mjs new file mode 100644 index 00000000..99f58e19 --- /dev/null +++ b/tests/unit/attachment-status.test.mjs @@ -0,0 +1,161 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ATTACHMENT_STATUS_MAX_CONCURRENCY, + ATTACHMENT_STATUS_MAX_TIMEOUT_MS, + normalizeAttachmentStatusConcurrency, + normalizeAttachmentStatusTimeoutMs, + refreshAttachmentStatuses, +} from '../../server/attachment_status.mjs'; + +test('attachment status configuration is bounded and fail-safe', () => { + assert.equal( + normalizeAttachmentStatusConcurrency(undefined), + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ); + assert.equal(normalizeAttachmentStatusConcurrency('4'), 4); + assert.equal( + normalizeAttachmentStatusConcurrency(0), + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ); + assert.equal( + normalizeAttachmentStatusConcurrency(1.5), + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ); + assert.equal( + normalizeAttachmentStatusConcurrency(999), + ATTACHMENT_STATUS_MAX_CONCURRENCY, + ); + assert.equal( + normalizeAttachmentStatusTimeoutMs(undefined), + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ); + assert.equal(normalizeAttachmentStatusTimeoutMs('25'), 25); + assert.equal( + normalizeAttachmentStatusTimeoutMs(-1), + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ); + assert.equal( + normalizeAttachmentStatusTimeoutMs(50_000), + ATTACHMENT_STATUS_MAX_TIMEOUT_MS, + ); +}); + +test('refresh validates its dependency contract', async () => { + await assert.rejects( + () => refreshAttachmentStatuses(null, {}), + /rows must be an array/, + ); + await assert.rejects( + () => refreshAttachmentStatuses([], { updateStatus() {} }), + /jobStatus must be a function/, + ); + await assert.rejects( + () => refreshAttachmentStatuses([], { jobStatus() {} }), + /updateStatus must be a function/, + ); +}); + +test('empty and settled rows perform no downstream work', async () => { + const dependencies = { + jobStatus: async () => { throw new Error('must not run'); }, + updateStatus: () => { throw new Error('must not run'); }, + }; + assert.deepEqual( + await refreshAttachmentStatuses([], dependencies), + { attempted: 0, changed: 0, failed: 0, deferred: 0 }, + ); + + const metrics = {}; + const counts = await refreshAttachmentStatuses( + [{ id: 1, status: 'SUCCEEDED', jobId: 'job-1' }], + { ...dependencies, metrics }, + ); + assert.deepEqual(counts, { attempted: 0, changed: 0, failed: 0, deferred: 0 }); + assert.equal(metrics.attachmentStatusRefreshAttempted, 0); + assert.equal(metrics.attachmentStatusRefreshChanged, 0); + assert.equal(metrics.attachmentStatusRefreshFailed, 0); + assert.equal(metrics.attachmentStatusRefreshDeferred, 0); +}); + +test('100 pending rows respect configured concurrency and persist only changes', async () => { + const rows = Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + jobId: `job-${index + 1}`, + status: index % 3 === 0 ? 'RUNNING' : 'PENDING', + })); + let active = 0; + let peak = 0; + const updates = []; + const metrics = { + attachmentStatusRefreshAttempted: 10, + attachmentStatusRefreshChanged: 20, + attachmentStatusRefreshFailed: 30, + attachmentStatusRefreshDeferred: 40, + }; + + const counts = await refreshAttachmentStatuses(rows, { + orgId: 7, + userId: 9, + concurrency: 8, + timeoutMs: 1_000, + metrics, + jobStatus: async (orgId, userId, jobId, { signal }) => { + assert.equal(orgId, 7); + assert.equal(userId, 9); + assert.equal(signal.aborted, false); + active += 1; + peak = Math.max(peak, active); + const rowNumber = Number(jobId.split('-')[1]); + await new Promise((resolve) => setTimeout(resolve, rowNumber % 3)); + active -= 1; + return rowNumber % 2 === 0 ? 'SUCCEEDED' : rows[rowNumber - 1].status; + }, + updateStatus: async (status, attachmentId) => updates.push([status, attachmentId]), + }); + + assert.ok(peak <= 8, `peak concurrency ${peak} exceeded configured limit`); + assert.deepEqual(counts, { attempted: 100, changed: 50, failed: 0, deferred: 0 }); + assert.equal(updates.length, 50); + assert.equal(metrics.attachmentStatusRefreshAttempted, 110); + assert.equal(metrics.attachmentStatusRefreshChanged, 70); + assert.equal(metrics.attachmentStatusRefreshFailed, 30); + assert.equal(metrics.attachmentStatusRefreshDeferred, 40); +}); + +test('invalid identifiers and downstream, timeout, or write failures remain isolated', async () => { + const rows = [ + { id: 1, jobId: null, status: 'PENDING' }, + { id: 2, jobId: '', status: 'RUNNING' }, + { id: 3, jobId: ' ', status: 'PENDING' }, + { id: 4, jobId: 'throws', status: 'PENDING' }, + { id: 5, jobId: 'invalid-status', status: 'PENDING' }, + { id: 6, jobId: 'write-fails', status: 'PENDING' }, + { id: 7, jobId: 'times-out', status: 'PENDING' }, + ]; + let aborted = false; + + const counts = await refreshAttachmentStatuses(rows, { + concurrency: 3, + timeoutMs: 5, + jobStatus: async (_orgId, _userId, jobId, { signal }) => { + if (jobId === 'throws') throw new Error('downstream failure'); + if (jobId === 'invalid-status') return 'UNKNOWN'; + if (jobId === 'write-fails') return 'SUCCEEDED'; + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + aborted = true; + reject(new Error('aborted')); + }, { once: true }); + }); + }, + updateStatus: () => { throw new Error('write failure'); }, + }); + + assert.equal(aborted, true); + assert.deepEqual(counts, { attempted: 4, changed: 0, failed: 4, deferred: 3 }); + assert.equal(rows[5].status, 'PENDING'); + assert.equal(rows[6].status, 'PENDING'); +}); From f8077e06ba7b194ba5ada0a651b4d34fe60d67d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 15:45:18 +0900 Subject: [PATCH 09/45] test(clearfolio): cover status cancellation signal --- tests/unit/clearfolio-status-signal.test.mjs | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/unit/clearfolio-status-signal.test.mjs diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs new file mode 100644 index 00000000..180eee53 --- /dev/null +++ b/tests/unit/clearfolio-status-signal.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +test('Clearfolio jobStatus forwards the caller abort signal', async () => { + process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; + const originalFetch = globalThis.fetch; + let observedSignal; + globalThis.fetch = async (_url, options) => { + observedSignal = options.signal; + return { json: async () => ({ status: 'RUNNING' }) }; + }; + + try { + const { jobStatus } = await import('../../server/clearfolio.mjs?status-signal-test=1'); + const controller = new AbortController(); + const status = await jobStatus(1, 2, 'job-1', { signal: controller.signal }); + assert.equal(status, 'RUNNING'); + assert.equal(observedSignal, controller.signal); + } finally { + globalThis.fetch = originalFetch; + delete process.env.CLEARFOLIO_URL; + } +}); From f455f99e7731f7b45d64be164fef3ba8453cb5fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 15:45:56 +0900 Subject: [PATCH 10/45] test(api): cover attachment status refresh contract --- tests/api/attachment-status.test.mjs | 112 +++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/api/attachment-status.test.mjs diff --git a/tests/api/attachment-status.test.mjs b/tests/api/attachment-status.test.mjs new file mode 100644 index 00000000..58f2ffbd --- /dev/null +++ b/tests/api/attachment-status.test.mjs @@ -0,0 +1,112 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY = '2'; +process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS = '500'; + +const { app } = await import('../../server/app.mjs'); +const { db } = await import('../../server/db.mjs'); + +const jsonRequest = (path, options = {}) => app.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, +}); + +async function upload(projectId, token, taskId) { + const form = new FormData(); + form.append( + 'file', + new Blob([`content-${taskId}`], { type: 'text/plain' }), + `${taskId}.txt`, + ); + form.set('taskId', taskId); + const response = await app.request(`/api/projects/${projectId}/attachments`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: form, + }); + assert.equal(response.status, 200); + return response.json(); +} + +test('attachment listing refreshes without N+1 queries or internal identifier leakage', async () => { + let response = await jsonRequest('/api/auth/signup', { + method: 'POST', + body: JSON.stringify({ + email: 'attachments@scopeweave.test', + password: 'password123', + name: 'Attachments', + }), + }); + assert.equal(response.status, 200); + const token = (await response.json()).token; + const auth = { authorization: `Bearer ${token}` }; + + response = await jsonRequest('/api/me', { headers: auth }); + const userId = (await response.json()).user.id; + response = await jsonRequest('/api/projects', { + method: 'POST', + headers: auth, + body: JSON.stringify({ name: 'Attachment Status Project' }), + }); + assert.equal(response.status, 200); + const projectId = (await response.json()).id; + + const first = await upload(projectId, token, 'task-a'); + const second = await upload(projectId, token, 'task-b'); + db.prepare("UPDATE attachments SET status = 'PENDING' WHERE id IN (?, ?)") + .run(first.id, second.id); + db.prepare( + 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)', + ).run(projectId, 'task-missing', 'missing.txt', 'text/plain', 1, '', 'PENDING', userId); + + response = await jsonRequest( + `/api/projects/${projectId}/attachments?taskId=task-a`, + { headers: auth }, + ); + assert.equal(response.status, 200); + let attachments = (await response.json()).attachments; + assert.equal(attachments.length, 1); + assert.equal(attachments[0].taskId, 'task-a'); + assert.equal(attachments[0].status, 'SUCCEEDED'); + assert.equal(Object.hasOwn(attachments[0], 'jobId'), false); + + response = await jsonRequest(`/api/projects/${projectId}/attachments`, { headers: auth }); + assert.equal(response.status, 200); + attachments = (await response.json()).attachments; + assert.equal(attachments.length, 3); + assert.equal(attachments.every((row) => !Object.hasOwn(row, 'jobId')), true); + assert.equal(attachments.find((row) => row.taskId === 'task-b').status, 'SUCCEEDED'); + assert.equal(attachments.find((row) => row.taskId === 'task-missing').status, 'PENDING'); + + response = await jsonRequest('/api/metrics'); + assert.equal(response.status, 200); + const metrics = await response.json(); + assert.equal(metrics.attachmentStatusRefreshAttempted, 2); + assert.equal(metrics.attachmentStatusRefreshChanged, 2); + assert.equal(metrics.attachmentStatusRefreshFailed, 0); + assert.equal(metrics.attachmentStatusRefreshDeferred, 1); + + response = await jsonRequest('/api/metrics?format=prometheus'); + assert.equal(response.status, 200); + const prometheus = await response.text(); + assert.match(prometheus, /scopeweave_attachment_status_refresh_attempted 2/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_changed 2/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_failed 0/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_deferred 1/); + + const source = readFileSync('server/app.mjs', 'utf8'); + const routeStart = source.indexOf("app.get('/api/projects/:id/attachments'"); + const routeEnd = source.indexOf('// 열람:', routeStart); + const route = source.slice(routeStart, routeEnd); + assert.ok(routeStart >= 0 && routeEnd > routeStart, 'attachment list route is discoverable'); + assert.equal((route.match(/a\.job_id AS jobId/g) || []).length, 2); + assert.doesNotMatch(route, /SELECT job_id FROM attachments/); + assert.match( + route, + /rows\.map\(\(\{ jobId: _internalJobId, \.\.\.publicRow \}\) => publicRow\)/, + ); +}); From 0d6b439a9e3c475d305641e541f5cdd4aaca05d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 15:46:55 +0900 Subject: [PATCH 11/45] feat(clearfolio): make status lookups abortable --- server/clearfolio.mjs | 85 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 15 deletions(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index ae5cd8f3..64c04f9e 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -7,11 +7,19 @@ const CF_URL = (process.env.CLEARFOLIO_URL || '').replace(/\/$/, ''); const CF_SECRET = process.env.CLEARFOLIO_HMAC_SECRET || ''; const PERMISSIONS = 'job:create,job:read,viewer:read,artifact-link:create'; +/** Whether ScopeWeave is using its in-memory Clearfolio development adapter. */ export const clearfolioMock = !CF_URL; -// Clearfolio TenantAccessService.signClaims와 동일한 규격: -// payload = tenantId \n subjectId \n permissions \n issuedAt(epoch초), -// HMAC-SHA256 → base64url(무패딩). +/** + * Sign Clearfolio tenant claims using the TenantAccessService wire contract. + * + * @param {string|number} tenantId - Clearfolio tenant identifier. + * @param {string|number} subjectId - Clearfolio subject identifier. + * @param {string} permissions - Comma-separated permission set. + * @param {string|number} issuedAt - Claim issue time in epoch seconds. + * @param {string} secret - Shared HMAC secret. + * @returns {string} Unpadded base64url HMAC-SHA256 signature. + */ export function signClaims(tenantId, subjectId, permissions, issuedAt, secret) { const payload = [tenantId, subjectId, permissions, issuedAt].join('\n'); return createHmac('sha256', secret).update(payload).digest('base64url'); @@ -28,7 +36,13 @@ function tenantHeaders(orgId, userId) { if (CF_SECRET) { const issuedAt = String(Math.floor(Date.now() / 1000)); headers['X-Clearfolio-Claims-Issued-At'] = issuedAt; - headers['X-Clearfolio-Claims-Signature'] = signClaims(tenantId, subjectId, PERMISSIONS, issuedAt, CF_SECRET); + headers['X-Clearfolio-Claims-Signature'] = signClaims( + tenantId, + subjectId, + PERMISSIONS, + issuedAt, + CF_SECRET, + ); } return headers; } @@ -36,8 +50,23 @@ function tenantHeaders(orgId, userId) { // ---- mock store (dev/test 전용; 재시작 시 소실) ---- const mockDocs = new Map(); // jobId -> { name, mime, bytes } let mockSeq = 0; + +/** + * Read one in-memory mock artifact. + * + * @param {string} jobId - Mock conversion job identifier. + * @returns {{name:string,mime:string,bytes:Uint8Array}|null} Stored artifact or null. + */ export const mockArtifact = (jobId) => mockDocs.get(jobId) || null; +/** + * Submit a document-conversion job to Clearfolio or the in-memory adapter. + * + * @param {string|number} orgId - ScopeWeave organization identifier. + * @param {string|number} userId - Requesting user identifier. + * @param {{name:string,mime:string,bytes:Uint8Array}} document - Uploaded document. + * @returns {Promise<{jobId:string,status:string}>} Conversion job identity and status. + */ export async function submitJob(orgId, userId, { name, mime, bytes }) { if (clearfolioMock) { const jobId = `mockcf-${++mockSeq}`; @@ -52,35 +81,61 @@ export async function submitJob(orgId, userId, { name, mime, bytes }) { body: form, }); const data = await res.json().catch(() => ({})); - if (!res.ok || !data.jobId) throw new Error(data.message || `clearfolio submit failed (${res.status})`); + if (!res.ok || !data.jobId) { + throw new Error(data.message || `clearfolio submit failed (${res.status})`); + } return { jobId: data.jobId, status: data.status || 'PENDING' }; } -export async function jobStatus(orgId, userId, jobId) { +/** + * Read a Clearfolio conversion status with optional caller-owned cancellation. + * + * @param {string|number} orgId - ScopeWeave organization identifier. + * @param {string|number} userId - Requesting user identifier. + * @param {string} jobId - Clearfolio conversion job identifier. + * @param {{signal?:AbortSignal}} [options] - Optional request cancellation signal. + * @returns {Promise} Downstream conversion status. + */ +export async function jobStatus(orgId, userId, jobId, { signal } = {}) { if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { headers: tenantHeaders(orgId, userId), + signal, }); const data = await res.json().catch(() => ({})); return data.status || 'FAILED'; } -// SUCCEEDED 잡의 서명 아티팩트 URL 발급 → 뷰어/직접 열람용 절대 URL 반환. +/** + * Issue an artifact-view URL for a completed Clearfolio job. + * + * @param {string|number} orgId - ScopeWeave organization identifier. + * @param {string|number} userId - Requesting user identifier. + * @param {string} jobId - Clearfolio conversion job identifier. + * @returns {Promise} ScopeWeave mock URL or absolute Clearfolio viewer URL. + */ export async function artifactUrl(orgId, userId, jobId) { if (clearfolioMock) return `/api/mock-clearfolio/${encodeURIComponent(jobId)}`; - const res = await fetch(`${CF_URL}/api/v1/viewer/${encodeURIComponent(jobId)}/artifact-links`, { - method: 'POST', - headers: tenantHeaders(orgId, userId), - }); + const res = await fetch( + `${CF_URL}/api/v1/viewer/${encodeURIComponent(jobId)}/artifact-links`, + { + method: 'POST', + headers: tenantHeaders(orgId, userId), + }, + ); const data = await res.json().catch(() => ({})); const link = data.artifactUrl || data.url || data.signedUrl; - if (!res.ok || !link) throw new Error(data.message || `clearfolio artifact-link failed (${res.status})`); + if (!res.ok || !link) { + throw new Error(data.message || `clearfolio artifact-link failed (${res.status})`); + } // PDF.js 뷰어 페이지 우선(clearfolio external artifactToken 모드): 토큰을 // 추출해 /viewer/{docId}?artifactToken=… 으로 보낸다. 실패 시 원시 아티팩트. try { - const u = new URL(link, CF_URL); - const tok = u.searchParams.get('artifactToken'); - if (tok) return `${CF_URL}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(tok)}`; + const url = new URL(link, CF_URL); + const token = url.searchParams.get('artifactToken'); + if (token) { + return `${CF_URL}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(token)}`; + } } catch { /* fall through to raw link */ } return link.startsWith('http') ? link : `${CF_URL}${link}`; } From ff15fcafed662cd1230c11ff336f6b53e2b571c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 15:47:24 +0900 Subject: [PATCH 12/45] test(attachments): wire refresh coverage and API regressions --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 9258d65f..584d8fc6 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,11 @@ }, "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", - "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/app.mjs --include=server/auth.mjs --reporter=json --reporter=json-summary npm run test:coverage", + "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --reporter=json --reporter=json-summary npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs", - "test:coverage": "node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs", + "test:coverage": "node tests/unit/attachment-status.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright test tests/e2e/cloud.spec.js", From 0cc38092b2d1a9c07b41f53caed413a219172ed9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 15:48:24 +0900 Subject: [PATCH 13/45] docs(changelog): record bounded attachment refresh --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e84f41f8..48498b62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Attachment-list status refresh now removes the per-row database lookup, + uses a configurable bounded worker pool with abortable downstream timeouts, + preserves stale status after isolated failures, excludes internal conversion + identifiers from responses, and reports attempted, changed, failed, and + deferred refresh counters. - 프로젝트 이름 입력 필드에 입력 예시(placeholder)를 추가하여 사용자 편의성을 개선했습니다. - 데이터 테이블의 반복되는 액션 버튼에 컨텍스트 정보(작업명)를 포함한 명시적인 ARIA 레이블을 추가하고, 유효성 검사 에러를 폼 필드에 연결하여 접근성을 개선했습니다. - `createGanttBarElement`, `renderGantt`, `buildWeekdayTimeline`에서 반복적으로 호출되던 `compareDateStrings`를 직접적인 문자열 비교 연산(`>=`, `<=`)으로 교체하여 O(N*D) 복잡도의 캐시 스레싱과 정규식 검사를 방지했습니다. @@ -61,4 +66,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) -- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. \ No newline at end of file +- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. From ee4930b335448b3cbd9b2ea82a8fb4cf09f0dd99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 15:50:08 +0900 Subject: [PATCH 14/45] ci: make attachment repair marker-based --- .../repair-attachment-status-refresh.yml | 599 +++--------------- 1 file changed, 92 insertions(+), 507 deletions(-) diff --git a/.github/workflows/repair-attachment-status-refresh.yml b/.github/workflows/repair-attachment-status-refresh.yml index 7e7c56d9..88995d91 100644 --- a/.github/workflows/repair-attachment-status-refresh.yml +++ b/.github/workflows/repair-attachment-status-refresh.yml @@ -31,520 +31,86 @@ jobs: node-version: 22.13.0 cache: npm - - name: Apply bounded refresh implementation and regression tests + - name: Patch the attachment-list integration shell: bash run: | set -euo pipefail python3 <<'PY' from pathlib import Path - import json - - def replace_once(path, old, new): - file_path = Path(path) - text = file_path.read_text(encoding='utf-8') - count = text.count(old) - if count != 1: - raise SystemExit(f'{path}: expected one replacement target, found {count}') - file_path.write_text(text.replace(old, new, 1), encoding='utf-8') - - attachment_status = r'''/** Default maximum concurrent Clearfolio status lookups. */ - export const ATTACHMENT_STATUS_DEFAULT_CONCURRENCY = 8; - - /** Conservative hard ceiling for operator-configured lookup concurrency. */ - export const ATTACHMENT_STATUS_MAX_CONCURRENCY = 32; - - /** Default downstream status lookup timeout in milliseconds. */ - export const ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS = 3_000; - - /** Hard ceiling for a downstream status lookup timeout in milliseconds. */ - export const ATTACHMENT_STATUS_MAX_TIMEOUT_MS = 30_000; - - /** - * Normalize a positive integer while applying a conservative upper bound. - * - * @param {unknown} value - Untrusted environment or caller value. - * @param {number} fallback - Value used for missing or invalid input. - * @param {number} maximum - Largest accepted value. - * @returns {number} A safe positive integer no greater than `maximum`. - */ - function normalizeBoundedInteger(value, fallback, maximum) { - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1) return fallback; - return Math.min(parsed, maximum); - } - - /** - * Normalize the configured attachment-status worker count. - * - * @param {unknown} value - Environment or caller supplied value. - * @returns {number} An integer between 1 and 32, defaulting to 8. - */ - export function normalizeAttachmentStatusConcurrency(value) { - return normalizeBoundedInteger( - value, - ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, - ATTACHMENT_STATUS_MAX_CONCURRENCY, - ); - } - - /** - * Normalize the configured Clearfolio status timeout. - * - * @param {unknown} value - Environment or caller supplied value. - * @returns {number} A positive timeout no greater than 30 seconds. - */ - export function normalizeAttachmentStatusTimeoutMs(value) { - return normalizeBoundedInteger( - value, - ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, - ATTACHMENT_STATUS_MAX_TIMEOUT_MS, - ); - } - - /** - * Add one refresh result to process-level operational counters. - * - * @param {object|undefined} metrics - Mutable process metric registry. - * @param {{attempted:number,changed:number,failed:number,deferred:number}} counts - Refresh result. - * @returns {void} - */ - function addRefreshMetrics(metrics, counts) { - if (!metrics) return; - const fields = { - attachmentStatusRefreshAttempted: 'attempted', - attachmentStatusRefreshChanged: 'changed', - attachmentStatusRefreshFailed: 'failed', - attachmentStatusRefreshDeferred: 'deferred', - }; - for (const [metric, count] of Object.entries(fields)) { - metrics[metric] = (Number(metrics[metric]) || 0) + counts[count]; - } - } - - /** - * Refresh pending attachment statuses through a bounded worker pool. - * - * Rows are updated in place so the caller can serialize the refreshed public - * representation. Missing job identifiers and downstream or persistence - * failures preserve stale status and never fail the attachment-list response. - * - * @param {Array} rows - Attachment rows containing `id`, `status`, and `jobId`. - * @param {object} options - Downstream functions, tenant identifiers, limits, and metrics. - * @param {number|string} options.orgId - ScopeWeave organization identifier. - * @param {number|string} options.userId - Requesting user identifier. - * @param {(orgId: unknown, userId: unknown, jobId: string, options: {signal: AbortSignal}) => Promise} options.jobStatus - Downstream lookup. - * @param {(status: string, attachmentId: unknown) => unknown|Promise} options.updateStatus - Changed-only persistence callback. - * @param {unknown} [options.concurrency] - Maximum concurrent lookups. - * @param {unknown} [options.timeoutMs] - Per-lookup timeout in milliseconds. - * @param {object} [options.metrics] - Mutable process metrics object. - * @returns {Promise<{attempted:number,changed:number,failed:number,deferred:number}>} Structured counters. - */ - export async function refreshAttachmentStatuses(rows, options) { - if (!Array.isArray(rows)) throw new TypeError('rows must be an array'); - if (typeof options?.jobStatus !== 'function') throw new TypeError('jobStatus must be a function'); - if (typeof options?.updateStatus !== 'function') throw new TypeError('updateStatus must be a function'); - - const counts = { attempted: 0, changed: 0, failed: 0, deferred: 0 }; - const pending = rows.filter((row) => row?.status === 'PENDING' || row?.status === 'RUNNING'); - const concurrency = normalizeAttachmentStatusConcurrency(options.concurrency); - const timeoutMs = normalizeAttachmentStatusTimeoutMs(options.timeoutMs); - let cursor = 0; - - async function worker() { - for (;;) { - const index = cursor; - cursor += 1; - if (index >= pending.length) return; - const row = pending[index]; - const jobId = typeof row.jobId === 'string' ? row.jobId.trim() : ''; - if (!jobId) { - counts.deferred += 1; - continue; - } - - counts.attempted += 1; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const nextStatus = await options.jobStatus( - options.orgId, - options.userId, - jobId, - { signal: controller.signal }, - ); - if (typeof nextStatus !== 'string' || !nextStatus.trim()) { - throw new Error('invalid downstream status'); - } - if (nextStatus !== row.status) { - await options.updateStatus(nextStatus, row.id); - row.status = nextStatus; - counts.changed += 1; - } - } catch { - counts.failed += 1; - } finally { - clearTimeout(timer); - } - } - } - - const workerCount = Math.min(concurrency, pending.length); - await Promise.all(Array.from({ length: workerCount }, () => worker())); - addRefreshMetrics(options.metrics, counts); - return counts; - } - '''.strip() + '\n' - Path('server/attachment_status.mjs').write_text(attachment_status, encoding='utf-8') - - replace_once( - 'server/app.mjs', - "import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';\n", - "import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';\nimport { normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs';\n", - ) - - replace_once( - 'server/app.mjs', - "const metrics = { startedAt: new Date().toISOString(), requests: 0, s2xx: 0, s4xx: 0, s5xx: 0, signups: 0, projectsCreated: 0, webhookDeliveries: 0 };", - "const metrics = {\n startedAt: new Date().toISOString(),\n requests: 0,\n s2xx: 0,\n s4xx: 0,\n s5xx: 0,\n signups: 0,\n projectsCreated: 0,\n webhookDeliveries: 0,\n attachmentStatusRefreshAttempted: 0,\n attachmentStatusRefreshChanged: 0,\n attachmentStatusRefreshFailed: 0,\n attachmentStatusRefreshDeferred: 0,\n};", - ) - - replace_once( - 'server/app.mjs', - "const ATTACH_MAX_BYTES = 10 * 1024 * 1024;\n", - "const ATTACH_MAX_BYTES = 10 * 1024 * 1024;\nconst ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency(\n process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY,\n);\nconst ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs(\n process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS,\n);\nconst updateAttachmentStatusStatement = db.prepare(\n 'UPDATE attachments SET status = ? WHERE id = ?',\n);\n", - ) - - old_route = r''' const rows = (taskId - ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) - : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); - // PENDING 잡 상태 갱신(최선 노력). Concurrent, but bounded so a large - // attachment list cannot open unbounded simultaneous Clearfolio calls. - // ponytail: fixed chunk size 5; make it configurable only if rate limits bite. - const pending = rows.filter((r) => r.status === 'PENDING' || r.status === 'RUNNING'); - for (let i = 0; i < pending.length; i += 5) { - await Promise.all(pending.slice(i, i + 5).map(async (r) => { - try { - const jid = db.prepare('SELECT job_id FROM attachments WHERE id = ?').get(r.id).job_id; - const st = await jobStatus(p.org_id, uid, jid); - if (st !== r.status) { - db.prepare('UPDATE attachments SET status = ? WHERE id = ?').run(st, r.id); - r.status = st; - } - } catch { /* keep stale status */ } - })); - } - return c.json({ attachments: rows });''' - new_route = r''' const rows = (taskId - ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) - : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); - await refreshAttachmentStatuses(rows, { - orgId: p.org_id, - userId: uid, - jobStatus, - updateStatus: (status, attachmentId) => updateAttachmentStatusStatement.run(status, attachmentId), - concurrency: ATTACH_STATUS_CONCURRENCY, - timeoutMs: ATTACH_STATUS_TIMEOUT_MS, - metrics, - }); - const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); - return c.json({ attachments });''' - replace_once('server/app.mjs', old_route, new_route) - - old_job_status = r'''export async function jobStatus(orgId, userId, jobId) { - if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; - const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { - headers: tenantHeaders(orgId, userId), - }); - const data = await res.json().catch(() => ({})); - return data.status || 'FAILED'; - }''' - new_job_status = r'''/** - * Read a Clearfolio conversion status, optionally using a caller-owned abort signal. - * - * @param {number|string} orgId - ScopeWeave organization identifier. - * @param {number|string} userId - Requesting user identifier. - * @param {string} jobId - Clearfolio conversion job identifier. - * @param {{signal?: AbortSignal}} [options] - Optional request cancellation signal. - * @returns {Promise} Downstream conversion status. - */ - export async function jobStatus(orgId, userId, jobId, { signal } = {}) { - if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; - const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { - headers: tenantHeaders(orgId, userId), - signal, - }); - const data = await res.json().catch(() => ({})); - return data.status || 'FAILED'; - }''' - replace_once('server/clearfolio.mjs', old_job_status, new_job_status) - - unit_test = r'''import test from 'node:test'; - import assert from 'node:assert/strict'; - import { - ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, - ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, - ATTACHMENT_STATUS_MAX_CONCURRENCY, - ATTACHMENT_STATUS_MAX_TIMEOUT_MS, - normalizeAttachmentStatusConcurrency, - normalizeAttachmentStatusTimeoutMs, - refreshAttachmentStatuses, - } from '../../server/attachment_status.mjs'; - - test('attachment status configuration is bounded and fail-safe', () => { - assert.equal(normalizeAttachmentStatusConcurrency(undefined), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); - assert.equal(normalizeAttachmentStatusConcurrency('4'), 4); - assert.equal(normalizeAttachmentStatusConcurrency(0), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); - assert.equal(normalizeAttachmentStatusConcurrency(1.5), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); - assert.equal(normalizeAttachmentStatusConcurrency(999), ATTACHMENT_STATUS_MAX_CONCURRENCY); - assert.equal(normalizeAttachmentStatusTimeoutMs(undefined), ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS); - assert.equal(normalizeAttachmentStatusTimeoutMs('25'), 25); - assert.equal(normalizeAttachmentStatusTimeoutMs(-1), ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS); - assert.equal(normalizeAttachmentStatusTimeoutMs(50_000), ATTACHMENT_STATUS_MAX_TIMEOUT_MS); - }); - - test('refresh validates its dependency contract', async () => { - await assert.rejects(() => refreshAttachmentStatuses(null, {}), /rows must be an array/); - await assert.rejects(() => refreshAttachmentStatuses([], { updateStatus() {} }), /jobStatus must be a function/); - await assert.rejects(() => refreshAttachmentStatuses([], { jobStatus() {} }), /updateStatus must be a function/); - }); - - test('empty and settled rows perform no downstream work', async () => { - const metrics = {}; - const counts = await refreshAttachmentStatuses( - [{ id: 1, status: 'SUCCEEDED', jobId: 'job-1' }], - { - jobStatus: async () => { throw new Error('must not run'); }, - updateStatus: () => { throw new Error('must not run'); }, - metrics, - }, - ); - assert.deepEqual(counts, { attempted: 0, changed: 0, failed: 0, deferred: 0 }); - assert.equal(metrics.attachmentStatusRefreshAttempted, 0); - assert.equal(metrics.attachmentStatusRefreshChanged, 0); - assert.equal(metrics.attachmentStatusRefreshFailed, 0); - assert.equal(metrics.attachmentStatusRefreshDeferred, 0); - }); - test('100 pending rows respect configured concurrency and persist only changes', async () => { - const rows = Array.from({ length: 100 }, (_, index) => ({ - id: index + 1, - jobId: `job-${index + 1}`, - status: index % 3 === 0 ? 'RUNNING' : 'PENDING', - })); - let active = 0; - let peak = 0; - const updates = []; - const metrics = { - attachmentStatusRefreshAttempted: 10, - attachmentStatusRefreshChanged: 20, - attachmentStatusRefreshFailed: 30, - attachmentStatusRefreshDeferred: 40, - }; - const counts = await refreshAttachmentStatuses(rows, { - orgId: 7, - userId: 9, - concurrency: 8, - timeoutMs: 1_000, + path = Path('server/app.mjs') + text = path.read_text(encoding='utf-8') + + clearfolio_import = "import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';\n" + refresh_import = "import { normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs';\n" + if refresh_import not in text: + if text.count(clearfolio_import) != 1: + raise SystemExit('clearfolio import marker is not unique') + text = text.replace(clearfolio_import, clearfolio_import + refresh_import, 1) + + metrics_old = "const metrics = { startedAt: new Date().toISOString(), requests: 0, s2xx: 0, s4xx: 0, s5xx: 0, signups: 0, projectsCreated: 0, webhookDeliveries: 0 };" + metrics_new = """const metrics = { + startedAt: new Date().toISOString(), + requests: 0, + s2xx: 0, + s4xx: 0, + s5xx: 0, + signups: 0, + projectsCreated: 0, + webhookDeliveries: 0, + attachmentStatusRefreshAttempted: 0, + attachmentStatusRefreshChanged: 0, + attachmentStatusRefreshFailed: 0, + attachmentStatusRefreshDeferred: 0, + };""" + if 'attachmentStatusRefreshAttempted' not in text: + if text.count(metrics_old) != 1: + raise SystemExit('metrics marker is not unique') + text = text.replace(metrics_old, metrics_new, 1) + + constant_marker = 'const ATTACH_MAX_BYTES = 10 * 1024 * 1024;\n' + constant_block = """const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, + ); + const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, + ); + const updateAttachmentStatusStatement = db.prepare( + 'UPDATE attachments SET status = ? WHERE id = ?', + ); + """ + if 'const ATTACH_STATUS_CONCURRENCY' not in text: + if text.count(constant_marker) != 1: + raise SystemExit('attachment constant marker is not unique') + text = text.replace(constant_marker, constant_marker + constant_block, 1) + + route_marker = "app.get('/api/projects/:id/attachments', requireAuth, async (c) => {" + route_start = text.index(route_marker) + body_start = text.index(" const taskId = c.req.query('taskId');", route_start) + body_end_marker = ' return c.json({ attachments: rows });' + body_end = text.index(body_end_marker, body_start) + len(body_end_marker) + new_body = """ const taskId = c.req.query('taskId'); + const rows = (taskId + ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) + : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); + await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => + updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, metrics, - jobStatus: async (orgId, userId, jobId, { signal }) => { - assert.equal(orgId, 7); - assert.equal(userId, 9); - assert.equal(signal.aborted, false); - active += 1; - peak = Math.max(peak, active); - await new Promise((resolve) => setTimeout(resolve, Number(jobId.split('-')[1]) % 3)); - active -= 1; - const row = rows[Number(jobId.split('-')[1]) - 1]; - return Number(jobId.split('-')[1]) % 2 === 0 ? 'SUCCEEDED' : row.status; - }, - updateStatus: async (status, attachmentId) => updates.push([status, attachmentId]), }); - assert.ok(peak <= 8, `peak concurrency ${peak} exceeded configured limit`); - assert.deepEqual(counts, { attempted: 100, changed: 50, failed: 0, deferred: 0 }); - assert.equal(updates.length, 50); - assert.equal(metrics.attachmentStatusRefreshAttempted, 110); - assert.equal(metrics.attachmentStatusRefreshChanged, 70); - assert.equal(metrics.attachmentStatusRefreshFailed, 30); - assert.equal(metrics.attachmentStatusRefreshDeferred, 40); - }); - - test('invalid identifiers and downstream, timeout, or write failures remain isolated', async () => { - const rows = [ - { id: 1, jobId: null, status: 'PENDING' }, - { id: 2, jobId: '', status: 'RUNNING' }, - { id: 3, jobId: ' ', status: 'PENDING' }, - { id: 4, jobId: 'throws', status: 'PENDING' }, - { id: 5, jobId: 'invalid-status', status: 'PENDING' }, - { id: 6, jobId: 'write-fails', status: 'PENDING' }, - { id: 7, jobId: 'times-out', status: 'PENDING' }, - ]; - let aborted = false; - const counts = await refreshAttachmentStatuses(rows, { - concurrency: 3, - timeoutMs: 5, - jobStatus: async (_orgId, _userId, jobId, { signal }) => { - if (jobId === 'throws') throw new Error('downstream failure'); - if (jobId === 'invalid-status') return ''; - if (jobId === 'write-fails') return 'SUCCEEDED'; - return new Promise((resolve, reject) => { - signal.addEventListener('abort', () => { - aborted = true; - reject(new Error('aborted')); - }, { once: true }); - }); - }, - updateStatus: () => { throw new Error('write failure'); }, - }); - assert.equal(aborted, true); - assert.deepEqual(counts, { attempted: 4, changed: 0, failed: 4, deferred: 3 }); - assert.equal(rows[5].status, 'PENDING'); - assert.equal(rows[6].status, 'PENDING'); - }); - '''.strip() + '\n' - Path('tests/unit/attachment-status.test.mjs').write_text(unit_test, encoding='utf-8') - - signal_test = r'''import test from 'node:test'; - import assert from 'node:assert/strict'; - - test('Clearfolio jobStatus forwards the caller abort signal', async () => { - process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; - const originalFetch = globalThis.fetch; - let observedSignal; - globalThis.fetch = async (_url, options) => { - observedSignal = options.signal; - return { json: async () => ({ status: 'RUNNING' }) }; - }; - try { - const { jobStatus } = await import('../../server/clearfolio.mjs?status-signal-test=1'); - const controller = new AbortController(); - const status = await jobStatus(1, 2, 'job-1', { signal: controller.signal }); - assert.equal(status, 'RUNNING'); - assert.equal(observedSignal, controller.signal); - } finally { - globalThis.fetch = originalFetch; - delete process.env.CLEARFOLIO_URL; - } - }); - '''.strip() + '\n' - Path('tests/unit/clearfolio-status-signal.test.mjs').write_text(signal_test, encoding='utf-8') - - api_test = r'''import test from 'node:test'; - import assert from 'node:assert/strict'; - import { readFileSync } from 'node:fs'; - - process.env.SCOPEWEAVE_DB = ':memory:'; - process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY = '2'; - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS = '500'; - - const { app } = await import('../../server/app.mjs'); - const { db } = await import('../../server/db.mjs'); - - const jsonRequest = (path, options = {}) => app.request(path, { - ...options, - headers: { 'content-type': 'application/json', ...(options.headers || {}) }, - }); - - async function upload(projectId, token, taskId) { - const form = new FormData(); - form.append('file', new Blob([`content-${taskId}`], { type: 'text/plain' }), `${taskId}.txt`); - form.set('taskId', taskId); - const response = await app.request(`/api/projects/${projectId}/attachments`, { - method: 'POST', - headers: { authorization: `Bearer ${token}` }, - body: form, - }); - assert.equal(response.status, 200); - return response.json(); - } - - test('attachment listing refreshes without N+1 queries or internal identifier leakage', async () => { - let response = await jsonRequest('/api/auth/signup', { - method: 'POST', - body: JSON.stringify({ email: 'attachments@scopeweave.test', password: 'password123', name: 'Attachments' }), - }); - assert.equal(response.status, 200); - const token = (await response.json()).token; - const auth = { authorization: `Bearer ${token}` }; - - response = await jsonRequest('/api/me', { headers: auth }); - const userId = (await response.json()).user.id; - response = await jsonRequest('/api/projects', { - method: 'POST', - headers: auth, - body: JSON.stringify({ name: 'Attachment Status Project' }), - }); - const projectId = (await response.json()).id; - - const first = await upload(projectId, token, 'task-a'); - const second = await upload(projectId, token, 'task-b'); - db.prepare("UPDATE attachments SET status = 'PENDING' WHERE id IN (?, ?)").run(first.id, second.id); - db.prepare('INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)') - .run(projectId, 'task-missing', 'missing.txt', 'text/plain', 1, '', 'PENDING', userId); - - response = await jsonRequest(`/api/projects/${projectId}/attachments?taskId=task-a`, { headers: auth }); - assert.equal(response.status, 200); - let attachments = (await response.json()).attachments; - assert.equal(attachments.length, 1); - assert.equal(attachments[0].taskId, 'task-a'); - assert.equal(attachments[0].status, 'SUCCEEDED'); - assert.equal(Object.hasOwn(attachments[0], 'jobId'), false); - - response = await jsonRequest(`/api/projects/${projectId}/attachments`, { headers: auth }); - assert.equal(response.status, 200); - attachments = (await response.json()).attachments; - assert.equal(attachments.length, 3); - assert.equal(attachments.every((row) => !Object.hasOwn(row, 'jobId')), true); - assert.equal(attachments.find((row) => row.taskId === 'task-b').status, 'SUCCEEDED'); - assert.equal(attachments.find((row) => row.taskId === 'task-missing').status, 'PENDING'); - - response = await jsonRequest('/api/metrics'); - const metrics = await response.json(); - assert.equal(metrics.attachmentStatusRefreshAttempted, 2); - assert.equal(metrics.attachmentStatusRefreshChanged, 2); - assert.equal(metrics.attachmentStatusRefreshFailed, 0); - assert.equal(metrics.attachmentStatusRefreshDeferred, 1); - - const source = readFileSync('server/app.mjs', 'utf8'); - const routeStart = source.indexOf("app.get('/api/projects/:id/attachments'"); - const routeEnd = source.indexOf('// 열람:', routeStart); - const route = source.slice(routeStart, routeEnd); - assert.ok(routeStart >= 0 && routeEnd > routeStart, 'attachment list route is discoverable'); - assert.equal((route.match(/a\.job_id AS jobId/g) || []).length, 2); - assert.doesNotMatch(route, /SELECT job_id FROM attachments/); - assert.match(route, /rows\.map\(\(\{ jobId: _internalJobId, \.\.\.publicRow \}\) => publicRow\)/); - }); - '''.strip() + '\n' - Path('tests/api/attachment-status.test.mjs').write_text(api_test, encoding='utf-8') - - package_path = Path('package.json') - package = json.loads(package_path.read_text(encoding='utf-8')) - package['scripts']['coverage'] = package['scripts']['coverage'].replace( - '--include=server/app.mjs', - '--include=server/attachment_status.mjs --include=server/app.mjs', - ) - package['scripts']['test:api'] += ' && node tests/api/attachment-status.test.mjs' - package['scripts']['test:unit'] += ' && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs' - package['scripts']['test:coverage'] = ( - 'node tests/unit/attachment-status.test.mjs && ' - + package['scripts']['test:coverage'] - + ' && node tests/api/attachment-status.test.mjs' - ) - package_path.write_text(json.dumps(package, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') - - replace_once( - 'CHANGELOG.md', - '### Changed\n\n', - '### Changed\n\n- Attachment status refresh now removes the per-row database lookup, uses a configurable bounded worker pool with abortable downstream timeouts, isolates partial failures, excludes internal conversion identifiers from list responses, and exports attempted/changed/failed/deferred operational metrics.\n', - ) + const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); + return c.json({ attachments });""" + text = text[:body_start] + new_body + text[body_end:] + path.write_text(text, encoding='utf-8') PY - name: Install dependencies @@ -560,10 +126,29 @@ jobs: npm run test:unit npm run test:api npm run coverage + node scripts/ci/static_coverage_evidence.mjs docstrings npm run test:e2e:cloud + python3 - <<'PY' + import json + from pathlib import Path + + coverage = json.loads(Path('coverage/coverage-final.json').read_text()) + target = next( + item for name, item in coverage.items() + if name.replace('\\', '/').endswith('/server/attachment_status.mjs') + ) + for label, values in ( + ('statements', target['s'].values()), + ('functions', target['f'].values()), + ('branches', (hit for hits in target['b'].values() for hit in hits)), + ): + values = list(values) + if not values or any(hit == 0 for hit in values): + raise SystemExit(f'attachment_status.mjs lacks 100% {label} coverage') + PY git diff --check - - name: Commit verified repair and remove one-shot workflow + - name: Commit only the verified implementation shell: bash run: | set -euo pipefail From 62da5d0563cb1d066273c623ca37ef997744c9c1 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:51:32 +0000 Subject: [PATCH 15/45] Restore scorecard-analysis.yml for branch protection rule --- .../repair-attachment-status-refresh.yml | 599 +++++++++++++++--- .github/workflows/scorecard-analysis.yml | 38 ++ CHANGELOG.md | 7 +- package.json | 8 +- server/attachment_status.mjs | 175 ----- server/clearfolio.mjs | 85 +-- tests/api/attachment-status.test.mjs | 112 ---- tests/unit/attachment-status.test.mjs | 161 ----- tests/unit/clearfolio-status-signal.test.mjs | 23 - 9 files changed, 565 insertions(+), 643 deletions(-) create mode 100644 .github/workflows/scorecard-analysis.yml delete mode 100644 server/attachment_status.mjs delete mode 100644 tests/api/attachment-status.test.mjs delete mode 100644 tests/unit/attachment-status.test.mjs delete mode 100644 tests/unit/clearfolio-status-signal.test.mjs diff --git a/.github/workflows/repair-attachment-status-refresh.yml b/.github/workflows/repair-attachment-status-refresh.yml index 88995d91..7e7c56d9 100644 --- a/.github/workflows/repair-attachment-status-refresh.yml +++ b/.github/workflows/repair-attachment-status-refresh.yml @@ -31,86 +31,520 @@ jobs: node-version: 22.13.0 cache: npm - - name: Patch the attachment-list integration + - name: Apply bounded refresh implementation and regression tests shell: bash run: | set -euo pipefail python3 <<'PY' from pathlib import Path + import json + + def replace_once(path, old, new): + file_path = Path(path) + text = file_path.read_text(encoding='utf-8') + count = text.count(old) + if count != 1: + raise SystemExit(f'{path}: expected one replacement target, found {count}') + file_path.write_text(text.replace(old, new, 1), encoding='utf-8') + + attachment_status = r'''/** Default maximum concurrent Clearfolio status lookups. */ + export const ATTACHMENT_STATUS_DEFAULT_CONCURRENCY = 8; + + /** Conservative hard ceiling for operator-configured lookup concurrency. */ + export const ATTACHMENT_STATUS_MAX_CONCURRENCY = 32; + + /** Default downstream status lookup timeout in milliseconds. */ + export const ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS = 3_000; + + /** Hard ceiling for a downstream status lookup timeout in milliseconds. */ + export const ATTACHMENT_STATUS_MAX_TIMEOUT_MS = 30_000; + + /** + * Normalize a positive integer while applying a conservative upper bound. + * + * @param {unknown} value - Untrusted environment or caller value. + * @param {number} fallback - Value used for missing or invalid input. + * @param {number} maximum - Largest accepted value. + * @returns {number} A safe positive integer no greater than `maximum`. + */ + function normalizeBoundedInteger(value, fallback, maximum) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) return fallback; + return Math.min(parsed, maximum); + } + + /** + * Normalize the configured attachment-status worker count. + * + * @param {unknown} value - Environment or caller supplied value. + * @returns {number} An integer between 1 and 32, defaulting to 8. + */ + export function normalizeAttachmentStatusConcurrency(value) { + return normalizeBoundedInteger( + value, + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ATTACHMENT_STATUS_MAX_CONCURRENCY, + ); + } + + /** + * Normalize the configured Clearfolio status timeout. + * + * @param {unknown} value - Environment or caller supplied value. + * @returns {number} A positive timeout no greater than 30 seconds. + */ + export function normalizeAttachmentStatusTimeoutMs(value) { + return normalizeBoundedInteger( + value, + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ATTACHMENT_STATUS_MAX_TIMEOUT_MS, + ); + } + + /** + * Add one refresh result to process-level operational counters. + * + * @param {object|undefined} metrics - Mutable process metric registry. + * @param {{attempted:number,changed:number,failed:number,deferred:number}} counts - Refresh result. + * @returns {void} + */ + function addRefreshMetrics(metrics, counts) { + if (!metrics) return; + const fields = { + attachmentStatusRefreshAttempted: 'attempted', + attachmentStatusRefreshChanged: 'changed', + attachmentStatusRefreshFailed: 'failed', + attachmentStatusRefreshDeferred: 'deferred', + }; + for (const [metric, count] of Object.entries(fields)) { + metrics[metric] = (Number(metrics[metric]) || 0) + counts[count]; + } + } + + /** + * Refresh pending attachment statuses through a bounded worker pool. + * + * Rows are updated in place so the caller can serialize the refreshed public + * representation. Missing job identifiers and downstream or persistence + * failures preserve stale status and never fail the attachment-list response. + * + * @param {Array} rows - Attachment rows containing `id`, `status`, and `jobId`. + * @param {object} options - Downstream functions, tenant identifiers, limits, and metrics. + * @param {number|string} options.orgId - ScopeWeave organization identifier. + * @param {number|string} options.userId - Requesting user identifier. + * @param {(orgId: unknown, userId: unknown, jobId: string, options: {signal: AbortSignal}) => Promise} options.jobStatus - Downstream lookup. + * @param {(status: string, attachmentId: unknown) => unknown|Promise} options.updateStatus - Changed-only persistence callback. + * @param {unknown} [options.concurrency] - Maximum concurrent lookups. + * @param {unknown} [options.timeoutMs] - Per-lookup timeout in milliseconds. + * @param {object} [options.metrics] - Mutable process metrics object. + * @returns {Promise<{attempted:number,changed:number,failed:number,deferred:number}>} Structured counters. + */ + export async function refreshAttachmentStatuses(rows, options) { + if (!Array.isArray(rows)) throw new TypeError('rows must be an array'); + if (typeof options?.jobStatus !== 'function') throw new TypeError('jobStatus must be a function'); + if (typeof options?.updateStatus !== 'function') throw new TypeError('updateStatus must be a function'); + + const counts = { attempted: 0, changed: 0, failed: 0, deferred: 0 }; + const pending = rows.filter((row) => row?.status === 'PENDING' || row?.status === 'RUNNING'); + const concurrency = normalizeAttachmentStatusConcurrency(options.concurrency); + const timeoutMs = normalizeAttachmentStatusTimeoutMs(options.timeoutMs); + let cursor = 0; + + async function worker() { + for (;;) { + const index = cursor; + cursor += 1; + if (index >= pending.length) return; + const row = pending[index]; + const jobId = typeof row.jobId === 'string' ? row.jobId.trim() : ''; + if (!jobId) { + counts.deferred += 1; + continue; + } + + counts.attempted += 1; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const nextStatus = await options.jobStatus( + options.orgId, + options.userId, + jobId, + { signal: controller.signal }, + ); + if (typeof nextStatus !== 'string' || !nextStatus.trim()) { + throw new Error('invalid downstream status'); + } + if (nextStatus !== row.status) { + await options.updateStatus(nextStatus, row.id); + row.status = nextStatus; + counts.changed += 1; + } + } catch { + counts.failed += 1; + } finally { + clearTimeout(timer); + } + } + } + + const workerCount = Math.min(concurrency, pending.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + addRefreshMetrics(options.metrics, counts); + return counts; + } + '''.strip() + '\n' + Path('server/attachment_status.mjs').write_text(attachment_status, encoding='utf-8') + + replace_once( + 'server/app.mjs', + "import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';\n", + "import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';\nimport { normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs';\n", + ) + + replace_once( + 'server/app.mjs', + "const metrics = { startedAt: new Date().toISOString(), requests: 0, s2xx: 0, s4xx: 0, s5xx: 0, signups: 0, projectsCreated: 0, webhookDeliveries: 0 };", + "const metrics = {\n startedAt: new Date().toISOString(),\n requests: 0,\n s2xx: 0,\n s4xx: 0,\n s5xx: 0,\n signups: 0,\n projectsCreated: 0,\n webhookDeliveries: 0,\n attachmentStatusRefreshAttempted: 0,\n attachmentStatusRefreshChanged: 0,\n attachmentStatusRefreshFailed: 0,\n attachmentStatusRefreshDeferred: 0,\n};", + ) + + replace_once( + 'server/app.mjs', + "const ATTACH_MAX_BYTES = 10 * 1024 * 1024;\n", + "const ATTACH_MAX_BYTES = 10 * 1024 * 1024;\nconst ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency(\n process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY,\n);\nconst ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs(\n process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS,\n);\nconst updateAttachmentStatusStatement = db.prepare(\n 'UPDATE attachments SET status = ? WHERE id = ?',\n);\n", + ) + + old_route = r''' const rows = (taskId + ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) + : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); + // PENDING 잡 상태 갱신(최선 노력). Concurrent, but bounded so a large + // attachment list cannot open unbounded simultaneous Clearfolio calls. + // ponytail: fixed chunk size 5; make it configurable only if rate limits bite. + const pending = rows.filter((r) => r.status === 'PENDING' || r.status === 'RUNNING'); + for (let i = 0; i < pending.length; i += 5) { + await Promise.all(pending.slice(i, i + 5).map(async (r) => { + try { + const jid = db.prepare('SELECT job_id FROM attachments WHERE id = ?').get(r.id).job_id; + const st = await jobStatus(p.org_id, uid, jid); + if (st !== r.status) { + db.prepare('UPDATE attachments SET status = ? WHERE id = ?').run(st, r.id); + r.status = st; + } + } catch { /* keep stale status */ } + })); + } + return c.json({ attachments: rows });''' + new_route = r''' const rows = (taskId + ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) + : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); + await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + metrics, + }); + const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); + return c.json({ attachments });''' + replace_once('server/app.mjs', old_route, new_route) + + old_job_status = r'''export async function jobStatus(orgId, userId, jobId) { + if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; + const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { + headers: tenantHeaders(orgId, userId), + }); + const data = await res.json().catch(() => ({})); + return data.status || 'FAILED'; + }''' + new_job_status = r'''/** + * Read a Clearfolio conversion status, optionally using a caller-owned abort signal. + * + * @param {number|string} orgId - ScopeWeave organization identifier. + * @param {number|string} userId - Requesting user identifier. + * @param {string} jobId - Clearfolio conversion job identifier. + * @param {{signal?: AbortSignal}} [options] - Optional request cancellation signal. + * @returns {Promise} Downstream conversion status. + */ + export async function jobStatus(orgId, userId, jobId, { signal } = {}) { + if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; + const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { + headers: tenantHeaders(orgId, userId), + signal, + }); + const data = await res.json().catch(() => ({})); + return data.status || 'FAILED'; + }''' + replace_once('server/clearfolio.mjs', old_job_status, new_job_status) + + unit_test = r'''import test from 'node:test'; + import assert from 'node:assert/strict'; + import { + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ATTACHMENT_STATUS_MAX_CONCURRENCY, + ATTACHMENT_STATUS_MAX_TIMEOUT_MS, + normalizeAttachmentStatusConcurrency, + normalizeAttachmentStatusTimeoutMs, + refreshAttachmentStatuses, + } from '../../server/attachment_status.mjs'; + + test('attachment status configuration is bounded and fail-safe', () => { + assert.equal(normalizeAttachmentStatusConcurrency(undefined), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); + assert.equal(normalizeAttachmentStatusConcurrency('4'), 4); + assert.equal(normalizeAttachmentStatusConcurrency(0), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); + assert.equal(normalizeAttachmentStatusConcurrency(1.5), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); + assert.equal(normalizeAttachmentStatusConcurrency(999), ATTACHMENT_STATUS_MAX_CONCURRENCY); + assert.equal(normalizeAttachmentStatusTimeoutMs(undefined), ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS); + assert.equal(normalizeAttachmentStatusTimeoutMs('25'), 25); + assert.equal(normalizeAttachmentStatusTimeoutMs(-1), ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS); + assert.equal(normalizeAttachmentStatusTimeoutMs(50_000), ATTACHMENT_STATUS_MAX_TIMEOUT_MS); + }); + + test('refresh validates its dependency contract', async () => { + await assert.rejects(() => refreshAttachmentStatuses(null, {}), /rows must be an array/); + await assert.rejects(() => refreshAttachmentStatuses([], { updateStatus() {} }), /jobStatus must be a function/); + await assert.rejects(() => refreshAttachmentStatuses([], { jobStatus() {} }), /updateStatus must be a function/); + }); + + test('empty and settled rows perform no downstream work', async () => { + const metrics = {}; + const counts = await refreshAttachmentStatuses( + [{ id: 1, status: 'SUCCEEDED', jobId: 'job-1' }], + { + jobStatus: async () => { throw new Error('must not run'); }, + updateStatus: () => { throw new Error('must not run'); }, + metrics, + }, + ); + assert.deepEqual(counts, { attempted: 0, changed: 0, failed: 0, deferred: 0 }); + assert.equal(metrics.attachmentStatusRefreshAttempted, 0); + assert.equal(metrics.attachmentStatusRefreshChanged, 0); + assert.equal(metrics.attachmentStatusRefreshFailed, 0); + assert.equal(metrics.attachmentStatusRefreshDeferred, 0); + }); - path = Path('server/app.mjs') - text = path.read_text(encoding='utf-8') - - clearfolio_import = "import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';\n" - refresh_import = "import { normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs';\n" - if refresh_import not in text: - if text.count(clearfolio_import) != 1: - raise SystemExit('clearfolio import marker is not unique') - text = text.replace(clearfolio_import, clearfolio_import + refresh_import, 1) - - metrics_old = "const metrics = { startedAt: new Date().toISOString(), requests: 0, s2xx: 0, s4xx: 0, s5xx: 0, signups: 0, projectsCreated: 0, webhookDeliveries: 0 };" - metrics_new = """const metrics = { - startedAt: new Date().toISOString(), - requests: 0, - s2xx: 0, - s4xx: 0, - s5xx: 0, - signups: 0, - projectsCreated: 0, - webhookDeliveries: 0, - attachmentStatusRefreshAttempted: 0, - attachmentStatusRefreshChanged: 0, - attachmentStatusRefreshFailed: 0, - attachmentStatusRefreshDeferred: 0, - };""" - if 'attachmentStatusRefreshAttempted' not in text: - if text.count(metrics_old) != 1: - raise SystemExit('metrics marker is not unique') - text = text.replace(metrics_old, metrics_new, 1) - - constant_marker = 'const ATTACH_MAX_BYTES = 10 * 1024 * 1024;\n' - constant_block = """const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, - ); - const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, - ); - const updateAttachmentStatusStatement = db.prepare( - 'UPDATE attachments SET status = ? WHERE id = ?', - ); - """ - if 'const ATTACH_STATUS_CONCURRENCY' not in text: - if text.count(constant_marker) != 1: - raise SystemExit('attachment constant marker is not unique') - text = text.replace(constant_marker, constant_marker + constant_block, 1) - - route_marker = "app.get('/api/projects/:id/attachments', requireAuth, async (c) => {" - route_start = text.index(route_marker) - body_start = text.index(" const taskId = c.req.query('taskId');", route_start) - body_end_marker = ' return c.json({ attachments: rows });' - body_end = text.index(body_end_marker, body_start) + len(body_end_marker) - new_body = """ const taskId = c.req.query('taskId'); - const rows = (taskId - ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) - : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); - await refreshAttachmentStatuses(rows, { - orgId: p.org_id, - userId: uid, - jobStatus, - updateStatus: (status, attachmentId) => - updateAttachmentStatusStatement.run(status, attachmentId), - concurrency: ATTACH_STATUS_CONCURRENCY, - timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + test('100 pending rows respect configured concurrency and persist only changes', async () => { + const rows = Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + jobId: `job-${index + 1}`, + status: index % 3 === 0 ? 'RUNNING' : 'PENDING', + })); + let active = 0; + let peak = 0; + const updates = []; + const metrics = { + attachmentStatusRefreshAttempted: 10, + attachmentStatusRefreshChanged: 20, + attachmentStatusRefreshFailed: 30, + attachmentStatusRefreshDeferred: 40, + }; + const counts = await refreshAttachmentStatuses(rows, { + orgId: 7, + userId: 9, + concurrency: 8, + timeoutMs: 1_000, metrics, + jobStatus: async (orgId, userId, jobId, { signal }) => { + assert.equal(orgId, 7); + assert.equal(userId, 9); + assert.equal(signal.aborted, false); + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, Number(jobId.split('-')[1]) % 3)); + active -= 1; + const row = rows[Number(jobId.split('-')[1]) - 1]; + return Number(jobId.split('-')[1]) % 2 === 0 ? 'SUCCEEDED' : row.status; + }, + updateStatus: async (status, attachmentId) => updates.push([status, attachmentId]), }); - const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); - return c.json({ attachments });""" - text = text[:body_start] + new_body + text[body_end:] - path.write_text(text, encoding='utf-8') + assert.ok(peak <= 8, `peak concurrency ${peak} exceeded configured limit`); + assert.deepEqual(counts, { attempted: 100, changed: 50, failed: 0, deferred: 0 }); + assert.equal(updates.length, 50); + assert.equal(metrics.attachmentStatusRefreshAttempted, 110); + assert.equal(metrics.attachmentStatusRefreshChanged, 70); + assert.equal(metrics.attachmentStatusRefreshFailed, 30); + assert.equal(metrics.attachmentStatusRefreshDeferred, 40); + }); + + test('invalid identifiers and downstream, timeout, or write failures remain isolated', async () => { + const rows = [ + { id: 1, jobId: null, status: 'PENDING' }, + { id: 2, jobId: '', status: 'RUNNING' }, + { id: 3, jobId: ' ', status: 'PENDING' }, + { id: 4, jobId: 'throws', status: 'PENDING' }, + { id: 5, jobId: 'invalid-status', status: 'PENDING' }, + { id: 6, jobId: 'write-fails', status: 'PENDING' }, + { id: 7, jobId: 'times-out', status: 'PENDING' }, + ]; + let aborted = false; + const counts = await refreshAttachmentStatuses(rows, { + concurrency: 3, + timeoutMs: 5, + jobStatus: async (_orgId, _userId, jobId, { signal }) => { + if (jobId === 'throws') throw new Error('downstream failure'); + if (jobId === 'invalid-status') return ''; + if (jobId === 'write-fails') return 'SUCCEEDED'; + return new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + aborted = true; + reject(new Error('aborted')); + }, { once: true }); + }); + }, + updateStatus: () => { throw new Error('write failure'); }, + }); + assert.equal(aborted, true); + assert.deepEqual(counts, { attempted: 4, changed: 0, failed: 4, deferred: 3 }); + assert.equal(rows[5].status, 'PENDING'); + assert.equal(rows[6].status, 'PENDING'); + }); + '''.strip() + '\n' + Path('tests/unit/attachment-status.test.mjs').write_text(unit_test, encoding='utf-8') + + signal_test = r'''import test from 'node:test'; + import assert from 'node:assert/strict'; + + test('Clearfolio jobStatus forwards the caller abort signal', async () => { + process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; + const originalFetch = globalThis.fetch; + let observedSignal; + globalThis.fetch = async (_url, options) => { + observedSignal = options.signal; + return { json: async () => ({ status: 'RUNNING' }) }; + }; + try { + const { jobStatus } = await import('../../server/clearfolio.mjs?status-signal-test=1'); + const controller = new AbortController(); + const status = await jobStatus(1, 2, 'job-1', { signal: controller.signal }); + assert.equal(status, 'RUNNING'); + assert.equal(observedSignal, controller.signal); + } finally { + globalThis.fetch = originalFetch; + delete process.env.CLEARFOLIO_URL; + } + }); + '''.strip() + '\n' + Path('tests/unit/clearfolio-status-signal.test.mjs').write_text(signal_test, encoding='utf-8') + + api_test = r'''import test from 'node:test'; + import assert from 'node:assert/strict'; + import { readFileSync } from 'node:fs'; + + process.env.SCOPEWEAVE_DB = ':memory:'; + process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY = '2'; + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS = '500'; + + const { app } = await import('../../server/app.mjs'); + const { db } = await import('../../server/db.mjs'); + + const jsonRequest = (path, options = {}) => app.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, + }); + + async function upload(projectId, token, taskId) { + const form = new FormData(); + form.append('file', new Blob([`content-${taskId}`], { type: 'text/plain' }), `${taskId}.txt`); + form.set('taskId', taskId); + const response = await app.request(`/api/projects/${projectId}/attachments`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: form, + }); + assert.equal(response.status, 200); + return response.json(); + } + + test('attachment listing refreshes without N+1 queries or internal identifier leakage', async () => { + let response = await jsonRequest('/api/auth/signup', { + method: 'POST', + body: JSON.stringify({ email: 'attachments@scopeweave.test', password: 'password123', name: 'Attachments' }), + }); + assert.equal(response.status, 200); + const token = (await response.json()).token; + const auth = { authorization: `Bearer ${token}` }; + + response = await jsonRequest('/api/me', { headers: auth }); + const userId = (await response.json()).user.id; + response = await jsonRequest('/api/projects', { + method: 'POST', + headers: auth, + body: JSON.stringify({ name: 'Attachment Status Project' }), + }); + const projectId = (await response.json()).id; + + const first = await upload(projectId, token, 'task-a'); + const second = await upload(projectId, token, 'task-b'); + db.prepare("UPDATE attachments SET status = 'PENDING' WHERE id IN (?, ?)").run(first.id, second.id); + db.prepare('INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)') + .run(projectId, 'task-missing', 'missing.txt', 'text/plain', 1, '', 'PENDING', userId); + + response = await jsonRequest(`/api/projects/${projectId}/attachments?taskId=task-a`, { headers: auth }); + assert.equal(response.status, 200); + let attachments = (await response.json()).attachments; + assert.equal(attachments.length, 1); + assert.equal(attachments[0].taskId, 'task-a'); + assert.equal(attachments[0].status, 'SUCCEEDED'); + assert.equal(Object.hasOwn(attachments[0], 'jobId'), false); + + response = await jsonRequest(`/api/projects/${projectId}/attachments`, { headers: auth }); + assert.equal(response.status, 200); + attachments = (await response.json()).attachments; + assert.equal(attachments.length, 3); + assert.equal(attachments.every((row) => !Object.hasOwn(row, 'jobId')), true); + assert.equal(attachments.find((row) => row.taskId === 'task-b').status, 'SUCCEEDED'); + assert.equal(attachments.find((row) => row.taskId === 'task-missing').status, 'PENDING'); + + response = await jsonRequest('/api/metrics'); + const metrics = await response.json(); + assert.equal(metrics.attachmentStatusRefreshAttempted, 2); + assert.equal(metrics.attachmentStatusRefreshChanged, 2); + assert.equal(metrics.attachmentStatusRefreshFailed, 0); + assert.equal(metrics.attachmentStatusRefreshDeferred, 1); + + const source = readFileSync('server/app.mjs', 'utf8'); + const routeStart = source.indexOf("app.get('/api/projects/:id/attachments'"); + const routeEnd = source.indexOf('// 열람:', routeStart); + const route = source.slice(routeStart, routeEnd); + assert.ok(routeStart >= 0 && routeEnd > routeStart, 'attachment list route is discoverable'); + assert.equal((route.match(/a\.job_id AS jobId/g) || []).length, 2); + assert.doesNotMatch(route, /SELECT job_id FROM attachments/); + assert.match(route, /rows\.map\(\(\{ jobId: _internalJobId, \.\.\.publicRow \}\) => publicRow\)/); + }); + '''.strip() + '\n' + Path('tests/api/attachment-status.test.mjs').write_text(api_test, encoding='utf-8') + + package_path = Path('package.json') + package = json.loads(package_path.read_text(encoding='utf-8')) + package['scripts']['coverage'] = package['scripts']['coverage'].replace( + '--include=server/app.mjs', + '--include=server/attachment_status.mjs --include=server/app.mjs', + ) + package['scripts']['test:api'] += ' && node tests/api/attachment-status.test.mjs' + package['scripts']['test:unit'] += ' && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs' + package['scripts']['test:coverage'] = ( + 'node tests/unit/attachment-status.test.mjs && ' + + package['scripts']['test:coverage'] + + ' && node tests/api/attachment-status.test.mjs' + ) + package_path.write_text(json.dumps(package, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + + replace_once( + 'CHANGELOG.md', + '### Changed\n\n', + '### Changed\n\n- Attachment status refresh now removes the per-row database lookup, uses a configurable bounded worker pool with abortable downstream timeouts, isolates partial failures, excludes internal conversion identifiers from list responses, and exports attempted/changed/failed/deferred operational metrics.\n', + ) PY - name: Install dependencies @@ -126,29 +560,10 @@ jobs: npm run test:unit npm run test:api npm run coverage - node scripts/ci/static_coverage_evidence.mjs docstrings npm run test:e2e:cloud - python3 - <<'PY' - import json - from pathlib import Path - - coverage = json.loads(Path('coverage/coverage-final.json').read_text()) - target = next( - item for name, item in coverage.items() - if name.replace('\\', '/').endswith('/server/attachment_status.mjs') - ) - for label, values in ( - ('statements', target['s'].values()), - ('functions', target['f'].values()), - ('branches', (hit for hits in target['b'].values() for hit in hits)), - ): - values = list(values) - if not values or any(hit == 0 for hit in values): - raise SystemExit(f'attachment_status.mjs lacks 100% {label} coverage') - PY git diff --check - - name: Commit only the verified implementation + - name: Commit verified repair and remove one-shot workflow shell: bash run: | set -euo pipefail diff --git a/.github/workflows/scorecard-analysis.yml b/.github/workflows/scorecard-analysis.yml new file mode 100644 index 00000000..9c482842 --- /dev/null +++ b/.github/workflows/scorecard-analysis.yml @@ -0,0 +1,38 @@ +name: Scorecard analysis + +on: + push: + branches: ["develop"] + schedule: + - cron: "30 1 * * 6" + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + security-events: write + id-token: write + contents: read + issues: read + pull-requests: read + checks: read + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload to code scanning + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: results.sarif diff --git a/CHANGELOG.md b/CHANGELOG.md index 48498b62..e84f41f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,11 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Attachment-list status refresh now removes the per-row database lookup, - uses a configurable bounded worker pool with abortable downstream timeouts, - preserves stale status after isolated failures, excludes internal conversion - identifiers from responses, and reports attempted, changed, failed, and - deferred refresh counters. - 프로젝트 이름 입력 필드에 입력 예시(placeholder)를 추가하여 사용자 편의성을 개선했습니다. - 데이터 테이블의 반복되는 액션 버튼에 컨텍스트 정보(작업명)를 포함한 명시적인 ARIA 레이블을 추가하고, 유효성 검사 에러를 폼 필드에 연결하여 접근성을 개선했습니다. - `createGanttBarElement`, `renderGantt`, `buildWeekdayTimeline`에서 반복적으로 호출되던 `compareDateStrings`를 직접적인 문자열 비교 연산(`>=`, `<=`)으로 교체하여 O(N*D) 복잡도의 캐시 스레싱과 정규식 검사를 방지했습니다. @@ -66,4 +61,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) -- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. +- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. \ No newline at end of file diff --git a/package.json b/package.json index 584d8fc6..9258d65f 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,11 @@ }, "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", - "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --reporter=json --reporter=json-summary npm run test:coverage", + "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/app.mjs --include=server/auth.mjs --reporter=json --reporter=json-summary npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs", - "test:coverage": "node tests/unit/attachment-status.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs", + "test:coverage": "node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright test tests/e2e/cloud.spec.js", diff --git a/server/attachment_status.mjs b/server/attachment_status.mjs deleted file mode 100644 index 398f9520..00000000 --- a/server/attachment_status.mjs +++ /dev/null @@ -1,175 +0,0 @@ -/** Default maximum concurrent Clearfolio status lookups. */ -export const ATTACHMENT_STATUS_DEFAULT_CONCURRENCY = 8; - -/** Conservative hard ceiling for operator-configured lookup concurrency. */ -export const ATTACHMENT_STATUS_MAX_CONCURRENCY = 32; - -/** Default downstream status lookup timeout in milliseconds. */ -export const ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS = 3_000; - -/** Hard ceiling for a downstream status lookup timeout in milliseconds. */ -export const ATTACHMENT_STATUS_MAX_TIMEOUT_MS = 30_000; - -const ATTACHMENT_STATUS_VALUES = new Set(['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED']); - -/** - * Normalize a positive integer while applying a conservative upper bound. - * - * @param {unknown} value - Untrusted environment or caller value. - * @param {number} fallback - Value used for missing or invalid input. - * @param {number} maximum - Largest accepted value. - * @returns {number} A safe positive integer no greater than `maximum`. - */ -function normalizeBoundedInteger(value, fallback, maximum) { - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1) return fallback; - return Math.min(parsed, maximum); -} - -/** - * Normalize the configured attachment-status worker count. - * - * @param {unknown} value - Environment or caller supplied value. - * @returns {number} An integer between 1 and 32, defaulting to 8. - */ -export function normalizeAttachmentStatusConcurrency(value) { - return normalizeBoundedInteger( - value, - ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, - ATTACHMENT_STATUS_MAX_CONCURRENCY, - ); -} - -/** - * Normalize the configured Clearfolio status timeout. - * - * @param {unknown} value - Environment or caller supplied value. - * @returns {number} A positive timeout no greater than 30 seconds. - */ -export function normalizeAttachmentStatusTimeoutMs(value) { - return normalizeBoundedInteger( - value, - ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, - ATTACHMENT_STATUS_MAX_TIMEOUT_MS, - ); -} - -/** - * Add one refresh result to process-level operational counters. - * - * @param {object|undefined} metrics - Mutable process metric registry. - * @param {{attempted:number,changed:number,failed:number,deferred:number}} counts - Refresh result. - * @returns {void} - */ -function addRefreshMetrics(metrics, counts) { - if (!metrics) return; - const fields = { - attachmentStatusRefreshAttempted: 'attempted', - attachmentStatusRefreshChanged: 'changed', - attachmentStatusRefreshFailed: 'failed', - attachmentStatusRefreshDeferred: 'deferred', - }; - for (const [metric, count] of Object.entries(fields)) { - metrics[metric] = (Number(metrics[metric]) || 0) + counts[count]; - } -} - -/** - * Await one downstream lookup with an AbortSignal and a hard caller-side timeout. - * - * The explicit race means a non-compliant downstream adapter cannot hold a list - * response open forever even if it ignores the supplied AbortSignal. - * - * @param {() => Promise} lookup - Deferred downstream lookup. - * @param {AbortController} controller - Controller whose signal is passed downstream. - * @param {number} timeoutMs - Hard timeout in milliseconds. - * @returns {Promise} The downstream status. - */ -async function withTimeout(lookup, controller, timeoutMs) { - let timer; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => { - controller.abort(); - reject(new Error('attachment status lookup timed out')); - }, timeoutMs); - }); - try { - return await Promise.race([lookup(), timeout]); - } finally { - clearTimeout(timer); - } -} - -/** - * Refresh pending attachment statuses through a bounded worker pool. - * - * Rows are updated in place so the caller can serialize the refreshed public - * representation. Missing job identifiers and downstream or persistence - * failures preserve stale status and never fail the attachment-list response. - * - * @param {Array} rows - Attachment rows containing `id`, `status`, and `jobId`. - * @param {object} options - Downstream functions, tenant identifiers, limits, and metrics. - * @param {number|string} options.orgId - ScopeWeave organization identifier. - * @param {number|string} options.userId - Requesting user identifier. - * @param {(orgId: unknown, userId: unknown, jobId: string, options: {signal: AbortSignal}) => Promise} options.jobStatus - Downstream lookup. - * @param {(status: string, attachmentId: unknown) => unknown|Promise} options.updateStatus - Changed-only persistence callback. - * @param {unknown} [options.concurrency] - Maximum concurrent lookups. - * @param {unknown} [options.timeoutMs] - Per-lookup timeout in milliseconds. - * @param {object} [options.metrics] - Mutable process metrics object. - * @returns {Promise<{attempted:number,changed:number,failed:number,deferred:number}>} Structured counters. - */ -export async function refreshAttachmentStatuses(rows, options) { - if (!Array.isArray(rows)) throw new TypeError('rows must be an array'); - if (typeof options?.jobStatus !== 'function') throw new TypeError('jobStatus must be a function'); - if (typeof options?.updateStatus !== 'function') throw new TypeError('updateStatus must be a function'); - - const counts = { attempted: 0, changed: 0, failed: 0, deferred: 0 }; - const pending = rows.filter((row) => row?.status === 'PENDING' || row?.status === 'RUNNING'); - const concurrency = normalizeAttachmentStatusConcurrency(options.concurrency); - const timeoutMs = normalizeAttachmentStatusTimeoutMs(options.timeoutMs); - let cursor = 0; - - async function worker() { - for (;;) { - const index = cursor; - cursor += 1; - if (index >= pending.length) return; - const row = pending[index]; - const jobId = typeof row.jobId === 'string' ? row.jobId.trim() : ''; - if (!jobId) { - counts.deferred += 1; - continue; - } - - counts.attempted += 1; - const controller = new AbortController(); - try { - const nextStatus = await withTimeout( - () => options.jobStatus( - options.orgId, - options.userId, - jobId, - { signal: controller.signal }, - ), - controller, - timeoutMs, - ); - if (!ATTACHMENT_STATUS_VALUES.has(nextStatus)) { - throw new Error('invalid downstream status'); - } - if (nextStatus !== row.status) { - await options.updateStatus(nextStatus, row.id); - row.status = nextStatus; - counts.changed += 1; - } - } catch { - counts.failed += 1; - } - } - } - - const workerCount = Math.min(concurrency, pending.length); - await Promise.all(Array.from({ length: workerCount }, () => worker())); - addRefreshMetrics(options.metrics, counts); - return counts; -} diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index 64c04f9e..ae5cd8f3 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -7,19 +7,11 @@ const CF_URL = (process.env.CLEARFOLIO_URL || '').replace(/\/$/, ''); const CF_SECRET = process.env.CLEARFOLIO_HMAC_SECRET || ''; const PERMISSIONS = 'job:create,job:read,viewer:read,artifact-link:create'; -/** Whether ScopeWeave is using its in-memory Clearfolio development adapter. */ export const clearfolioMock = !CF_URL; -/** - * Sign Clearfolio tenant claims using the TenantAccessService wire contract. - * - * @param {string|number} tenantId - Clearfolio tenant identifier. - * @param {string|number} subjectId - Clearfolio subject identifier. - * @param {string} permissions - Comma-separated permission set. - * @param {string|number} issuedAt - Claim issue time in epoch seconds. - * @param {string} secret - Shared HMAC secret. - * @returns {string} Unpadded base64url HMAC-SHA256 signature. - */ +// Clearfolio TenantAccessService.signClaims와 동일한 규격: +// payload = tenantId \n subjectId \n permissions \n issuedAt(epoch초), +// HMAC-SHA256 → base64url(무패딩). export function signClaims(tenantId, subjectId, permissions, issuedAt, secret) { const payload = [tenantId, subjectId, permissions, issuedAt].join('\n'); return createHmac('sha256', secret).update(payload).digest('base64url'); @@ -36,13 +28,7 @@ function tenantHeaders(orgId, userId) { if (CF_SECRET) { const issuedAt = String(Math.floor(Date.now() / 1000)); headers['X-Clearfolio-Claims-Issued-At'] = issuedAt; - headers['X-Clearfolio-Claims-Signature'] = signClaims( - tenantId, - subjectId, - PERMISSIONS, - issuedAt, - CF_SECRET, - ); + headers['X-Clearfolio-Claims-Signature'] = signClaims(tenantId, subjectId, PERMISSIONS, issuedAt, CF_SECRET); } return headers; } @@ -50,23 +36,8 @@ function tenantHeaders(orgId, userId) { // ---- mock store (dev/test 전용; 재시작 시 소실) ---- const mockDocs = new Map(); // jobId -> { name, mime, bytes } let mockSeq = 0; - -/** - * Read one in-memory mock artifact. - * - * @param {string} jobId - Mock conversion job identifier. - * @returns {{name:string,mime:string,bytes:Uint8Array}|null} Stored artifact or null. - */ export const mockArtifact = (jobId) => mockDocs.get(jobId) || null; -/** - * Submit a document-conversion job to Clearfolio or the in-memory adapter. - * - * @param {string|number} orgId - ScopeWeave organization identifier. - * @param {string|number} userId - Requesting user identifier. - * @param {{name:string,mime:string,bytes:Uint8Array}} document - Uploaded document. - * @returns {Promise<{jobId:string,status:string}>} Conversion job identity and status. - */ export async function submitJob(orgId, userId, { name, mime, bytes }) { if (clearfolioMock) { const jobId = `mockcf-${++mockSeq}`; @@ -81,61 +52,35 @@ export async function submitJob(orgId, userId, { name, mime, bytes }) { body: form, }); const data = await res.json().catch(() => ({})); - if (!res.ok || !data.jobId) { - throw new Error(data.message || `clearfolio submit failed (${res.status})`); - } + if (!res.ok || !data.jobId) throw new Error(data.message || `clearfolio submit failed (${res.status})`); return { jobId: data.jobId, status: data.status || 'PENDING' }; } -/** - * Read a Clearfolio conversion status with optional caller-owned cancellation. - * - * @param {string|number} orgId - ScopeWeave organization identifier. - * @param {string|number} userId - Requesting user identifier. - * @param {string} jobId - Clearfolio conversion job identifier. - * @param {{signal?:AbortSignal}} [options] - Optional request cancellation signal. - * @returns {Promise} Downstream conversion status. - */ -export async function jobStatus(orgId, userId, jobId, { signal } = {}) { +export async function jobStatus(orgId, userId, jobId) { if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { headers: tenantHeaders(orgId, userId), - signal, }); const data = await res.json().catch(() => ({})); return data.status || 'FAILED'; } -/** - * Issue an artifact-view URL for a completed Clearfolio job. - * - * @param {string|number} orgId - ScopeWeave organization identifier. - * @param {string|number} userId - Requesting user identifier. - * @param {string} jobId - Clearfolio conversion job identifier. - * @returns {Promise} ScopeWeave mock URL or absolute Clearfolio viewer URL. - */ +// SUCCEEDED 잡의 서명 아티팩트 URL 발급 → 뷰어/직접 열람용 절대 URL 반환. export async function artifactUrl(orgId, userId, jobId) { if (clearfolioMock) return `/api/mock-clearfolio/${encodeURIComponent(jobId)}`; - const res = await fetch( - `${CF_URL}/api/v1/viewer/${encodeURIComponent(jobId)}/artifact-links`, - { - method: 'POST', - headers: tenantHeaders(orgId, userId), - }, - ); + const res = await fetch(`${CF_URL}/api/v1/viewer/${encodeURIComponent(jobId)}/artifact-links`, { + method: 'POST', + headers: tenantHeaders(orgId, userId), + }); const data = await res.json().catch(() => ({})); const link = data.artifactUrl || data.url || data.signedUrl; - if (!res.ok || !link) { - throw new Error(data.message || `clearfolio artifact-link failed (${res.status})`); - } + if (!res.ok || !link) throw new Error(data.message || `clearfolio artifact-link failed (${res.status})`); // PDF.js 뷰어 페이지 우선(clearfolio external artifactToken 모드): 토큰을 // 추출해 /viewer/{docId}?artifactToken=… 으로 보낸다. 실패 시 원시 아티팩트. try { - const url = new URL(link, CF_URL); - const token = url.searchParams.get('artifactToken'); - if (token) { - return `${CF_URL}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(token)}`; - } + const u = new URL(link, CF_URL); + const tok = u.searchParams.get('artifactToken'); + if (tok) return `${CF_URL}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(tok)}`; } catch { /* fall through to raw link */ } return link.startsWith('http') ? link : `${CF_URL}${link}`; } diff --git a/tests/api/attachment-status.test.mjs b/tests/api/attachment-status.test.mjs deleted file mode 100644 index 58f2ffbd..00000000 --- a/tests/api/attachment-status.test.mjs +++ /dev/null @@ -1,112 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; - -process.env.SCOPEWEAVE_DB = ':memory:'; -process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; -process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY = '2'; -process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS = '500'; - -const { app } = await import('../../server/app.mjs'); -const { db } = await import('../../server/db.mjs'); - -const jsonRequest = (path, options = {}) => app.request(path, { - ...options, - headers: { 'content-type': 'application/json', ...(options.headers || {}) }, -}); - -async function upload(projectId, token, taskId) { - const form = new FormData(); - form.append( - 'file', - new Blob([`content-${taskId}`], { type: 'text/plain' }), - `${taskId}.txt`, - ); - form.set('taskId', taskId); - const response = await app.request(`/api/projects/${projectId}/attachments`, { - method: 'POST', - headers: { authorization: `Bearer ${token}` }, - body: form, - }); - assert.equal(response.status, 200); - return response.json(); -} - -test('attachment listing refreshes without N+1 queries or internal identifier leakage', async () => { - let response = await jsonRequest('/api/auth/signup', { - method: 'POST', - body: JSON.stringify({ - email: 'attachments@scopeweave.test', - password: 'password123', - name: 'Attachments', - }), - }); - assert.equal(response.status, 200); - const token = (await response.json()).token; - const auth = { authorization: `Bearer ${token}` }; - - response = await jsonRequest('/api/me', { headers: auth }); - const userId = (await response.json()).user.id; - response = await jsonRequest('/api/projects', { - method: 'POST', - headers: auth, - body: JSON.stringify({ name: 'Attachment Status Project' }), - }); - assert.equal(response.status, 200); - const projectId = (await response.json()).id; - - const first = await upload(projectId, token, 'task-a'); - const second = await upload(projectId, token, 'task-b'); - db.prepare("UPDATE attachments SET status = 'PENDING' WHERE id IN (?, ?)") - .run(first.id, second.id); - db.prepare( - 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)', - ).run(projectId, 'task-missing', 'missing.txt', 'text/plain', 1, '', 'PENDING', userId); - - response = await jsonRequest( - `/api/projects/${projectId}/attachments?taskId=task-a`, - { headers: auth }, - ); - assert.equal(response.status, 200); - let attachments = (await response.json()).attachments; - assert.equal(attachments.length, 1); - assert.equal(attachments[0].taskId, 'task-a'); - assert.equal(attachments[0].status, 'SUCCEEDED'); - assert.equal(Object.hasOwn(attachments[0], 'jobId'), false); - - response = await jsonRequest(`/api/projects/${projectId}/attachments`, { headers: auth }); - assert.equal(response.status, 200); - attachments = (await response.json()).attachments; - assert.equal(attachments.length, 3); - assert.equal(attachments.every((row) => !Object.hasOwn(row, 'jobId')), true); - assert.equal(attachments.find((row) => row.taskId === 'task-b').status, 'SUCCEEDED'); - assert.equal(attachments.find((row) => row.taskId === 'task-missing').status, 'PENDING'); - - response = await jsonRequest('/api/metrics'); - assert.equal(response.status, 200); - const metrics = await response.json(); - assert.equal(metrics.attachmentStatusRefreshAttempted, 2); - assert.equal(metrics.attachmentStatusRefreshChanged, 2); - assert.equal(metrics.attachmentStatusRefreshFailed, 0); - assert.equal(metrics.attachmentStatusRefreshDeferred, 1); - - response = await jsonRequest('/api/metrics?format=prometheus'); - assert.equal(response.status, 200); - const prometheus = await response.text(); - assert.match(prometheus, /scopeweave_attachment_status_refresh_attempted 2/); - assert.match(prometheus, /scopeweave_attachment_status_refresh_changed 2/); - assert.match(prometheus, /scopeweave_attachment_status_refresh_failed 0/); - assert.match(prometheus, /scopeweave_attachment_status_refresh_deferred 1/); - - const source = readFileSync('server/app.mjs', 'utf8'); - const routeStart = source.indexOf("app.get('/api/projects/:id/attachments'"); - const routeEnd = source.indexOf('// 열람:', routeStart); - const route = source.slice(routeStart, routeEnd); - assert.ok(routeStart >= 0 && routeEnd > routeStart, 'attachment list route is discoverable'); - assert.equal((route.match(/a\.job_id AS jobId/g) || []).length, 2); - assert.doesNotMatch(route, /SELECT job_id FROM attachments/); - assert.match( - route, - /rows\.map\(\(\{ jobId: _internalJobId, \.\.\.publicRow \}\) => publicRow\)/, - ); -}); diff --git a/tests/unit/attachment-status.test.mjs b/tests/unit/attachment-status.test.mjs deleted file mode 100644 index 99f58e19..00000000 --- a/tests/unit/attachment-status.test.mjs +++ /dev/null @@ -1,161 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, - ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, - ATTACHMENT_STATUS_MAX_CONCURRENCY, - ATTACHMENT_STATUS_MAX_TIMEOUT_MS, - normalizeAttachmentStatusConcurrency, - normalizeAttachmentStatusTimeoutMs, - refreshAttachmentStatuses, -} from '../../server/attachment_status.mjs'; - -test('attachment status configuration is bounded and fail-safe', () => { - assert.equal( - normalizeAttachmentStatusConcurrency(undefined), - ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, - ); - assert.equal(normalizeAttachmentStatusConcurrency('4'), 4); - assert.equal( - normalizeAttachmentStatusConcurrency(0), - ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, - ); - assert.equal( - normalizeAttachmentStatusConcurrency(1.5), - ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, - ); - assert.equal( - normalizeAttachmentStatusConcurrency(999), - ATTACHMENT_STATUS_MAX_CONCURRENCY, - ); - assert.equal( - normalizeAttachmentStatusTimeoutMs(undefined), - ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, - ); - assert.equal(normalizeAttachmentStatusTimeoutMs('25'), 25); - assert.equal( - normalizeAttachmentStatusTimeoutMs(-1), - ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, - ); - assert.equal( - normalizeAttachmentStatusTimeoutMs(50_000), - ATTACHMENT_STATUS_MAX_TIMEOUT_MS, - ); -}); - -test('refresh validates its dependency contract', async () => { - await assert.rejects( - () => refreshAttachmentStatuses(null, {}), - /rows must be an array/, - ); - await assert.rejects( - () => refreshAttachmentStatuses([], { updateStatus() {} }), - /jobStatus must be a function/, - ); - await assert.rejects( - () => refreshAttachmentStatuses([], { jobStatus() {} }), - /updateStatus must be a function/, - ); -}); - -test('empty and settled rows perform no downstream work', async () => { - const dependencies = { - jobStatus: async () => { throw new Error('must not run'); }, - updateStatus: () => { throw new Error('must not run'); }, - }; - assert.deepEqual( - await refreshAttachmentStatuses([], dependencies), - { attempted: 0, changed: 0, failed: 0, deferred: 0 }, - ); - - const metrics = {}; - const counts = await refreshAttachmentStatuses( - [{ id: 1, status: 'SUCCEEDED', jobId: 'job-1' }], - { ...dependencies, metrics }, - ); - assert.deepEqual(counts, { attempted: 0, changed: 0, failed: 0, deferred: 0 }); - assert.equal(metrics.attachmentStatusRefreshAttempted, 0); - assert.equal(metrics.attachmentStatusRefreshChanged, 0); - assert.equal(metrics.attachmentStatusRefreshFailed, 0); - assert.equal(metrics.attachmentStatusRefreshDeferred, 0); -}); - -test('100 pending rows respect configured concurrency and persist only changes', async () => { - const rows = Array.from({ length: 100 }, (_, index) => ({ - id: index + 1, - jobId: `job-${index + 1}`, - status: index % 3 === 0 ? 'RUNNING' : 'PENDING', - })); - let active = 0; - let peak = 0; - const updates = []; - const metrics = { - attachmentStatusRefreshAttempted: 10, - attachmentStatusRefreshChanged: 20, - attachmentStatusRefreshFailed: 30, - attachmentStatusRefreshDeferred: 40, - }; - - const counts = await refreshAttachmentStatuses(rows, { - orgId: 7, - userId: 9, - concurrency: 8, - timeoutMs: 1_000, - metrics, - jobStatus: async (orgId, userId, jobId, { signal }) => { - assert.equal(orgId, 7); - assert.equal(userId, 9); - assert.equal(signal.aborted, false); - active += 1; - peak = Math.max(peak, active); - const rowNumber = Number(jobId.split('-')[1]); - await new Promise((resolve) => setTimeout(resolve, rowNumber % 3)); - active -= 1; - return rowNumber % 2 === 0 ? 'SUCCEEDED' : rows[rowNumber - 1].status; - }, - updateStatus: async (status, attachmentId) => updates.push([status, attachmentId]), - }); - - assert.ok(peak <= 8, `peak concurrency ${peak} exceeded configured limit`); - assert.deepEqual(counts, { attempted: 100, changed: 50, failed: 0, deferred: 0 }); - assert.equal(updates.length, 50); - assert.equal(metrics.attachmentStatusRefreshAttempted, 110); - assert.equal(metrics.attachmentStatusRefreshChanged, 70); - assert.equal(metrics.attachmentStatusRefreshFailed, 30); - assert.equal(metrics.attachmentStatusRefreshDeferred, 40); -}); - -test('invalid identifiers and downstream, timeout, or write failures remain isolated', async () => { - const rows = [ - { id: 1, jobId: null, status: 'PENDING' }, - { id: 2, jobId: '', status: 'RUNNING' }, - { id: 3, jobId: ' ', status: 'PENDING' }, - { id: 4, jobId: 'throws', status: 'PENDING' }, - { id: 5, jobId: 'invalid-status', status: 'PENDING' }, - { id: 6, jobId: 'write-fails', status: 'PENDING' }, - { id: 7, jobId: 'times-out', status: 'PENDING' }, - ]; - let aborted = false; - - const counts = await refreshAttachmentStatuses(rows, { - concurrency: 3, - timeoutMs: 5, - jobStatus: async (_orgId, _userId, jobId, { signal }) => { - if (jobId === 'throws') throw new Error('downstream failure'); - if (jobId === 'invalid-status') return 'UNKNOWN'; - if (jobId === 'write-fails') return 'SUCCEEDED'; - return new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => { - aborted = true; - reject(new Error('aborted')); - }, { once: true }); - }); - }, - updateStatus: () => { throw new Error('write failure'); }, - }); - - assert.equal(aborted, true); - assert.deepEqual(counts, { attempted: 4, changed: 0, failed: 4, deferred: 3 }); - assert.equal(rows[5].status, 'PENDING'); - assert.equal(rows[6].status, 'PENDING'); -}); diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs deleted file mode 100644 index 180eee53..00000000 --- a/tests/unit/clearfolio-status-signal.test.mjs +++ /dev/null @@ -1,23 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -test('Clearfolio jobStatus forwards the caller abort signal', async () => { - process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; - const originalFetch = globalThis.fetch; - let observedSignal; - globalThis.fetch = async (_url, options) => { - observedSignal = options.signal; - return { json: async () => ({ status: 'RUNNING' }) }; - }; - - try { - const { jobStatus } = await import('../../server/clearfolio.mjs?status-signal-test=1'); - const controller = new AbortController(); - const status = await jobStatus(1, 2, 'job-1', { signal: controller.signal }); - assert.equal(status, 'RUNNING'); - assert.equal(observedSignal, controller.signal); - } finally { - globalThis.fetch = originalFetch; - delete process.env.CLEARFOLIO_URL; - } -}); From 8ebd8cd9da45904af43709f372293c573e915223 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 15:55:46 +0900 Subject: [PATCH 16/45] docs(attachments): document refresh worker --- server/attachment_status.mjs | 185 +++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 server/attachment_status.mjs diff --git a/server/attachment_status.mjs b/server/attachment_status.mjs new file mode 100644 index 00000000..fb9acdc9 --- /dev/null +++ b/server/attachment_status.mjs @@ -0,0 +1,185 @@ +/** Default maximum concurrent Clearfolio status lookups. */ +export const ATTACHMENT_STATUS_DEFAULT_CONCURRENCY = 8; + +/** Conservative hard ceiling for operator-configured lookup concurrency. */ +export const ATTACHMENT_STATUS_MAX_CONCURRENCY = 32; + +/** Default downstream status lookup timeout in milliseconds. */ +export const ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS = 3_000; + +/** Hard ceiling for a downstream status lookup timeout in milliseconds. */ +export const ATTACHMENT_STATUS_MAX_TIMEOUT_MS = 30_000; + +/** Status values accepted from the Clearfolio conversion contract. */ +const ATTACHMENT_STATUS_VALUES = new Set(['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED']); + +/** + * Normalize a positive integer while applying a conservative upper bound. + * + * @param {unknown} value - Untrusted environment or caller value. + * @param {number} fallback - Value used for missing or invalid input. + * @param {number} maximum - Largest accepted value. + * @returns {number} A safe positive integer no greater than `maximum`. + */ +function normalizeBoundedInteger(value, fallback, maximum) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) return fallback; + return Math.min(parsed, maximum); +} + +/** + * Normalize the configured attachment-status worker count. + * + * @param {unknown} value - Environment or caller supplied value. + * @returns {number} An integer between 1 and 32, defaulting to 8. + */ +export function normalizeAttachmentStatusConcurrency(value) { + return normalizeBoundedInteger( + value, + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ATTACHMENT_STATUS_MAX_CONCURRENCY, + ); +} + +/** + * Normalize the configured Clearfolio status timeout. + * + * @param {unknown} value - Environment or caller supplied value. + * @returns {number} A positive timeout no greater than 30 seconds. + */ +export function normalizeAttachmentStatusTimeoutMs(value) { + return normalizeBoundedInteger( + value, + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ATTACHMENT_STATUS_MAX_TIMEOUT_MS, + ); +} + +/** + * Add one refresh result to process-level operational counters. + * + * @param {object|undefined} metrics - Mutable process metric registry. + * @param {{attempted:number,changed:number,failed:number,deferred:number}} counts - Refresh result. + * @returns {void} + */ +function addRefreshMetrics(metrics, counts) { + if (!metrics) return; + const fields = { + attachmentStatusRefreshAttempted: 'attempted', + attachmentStatusRefreshChanged: 'changed', + attachmentStatusRefreshFailed: 'failed', + attachmentStatusRefreshDeferred: 'deferred', + }; + for (const [metric, count] of Object.entries(fields)) { + metrics[metric] = (Number(metrics[metric]) || 0) + counts[count]; + } +} + +/** + * Await one downstream lookup with an AbortSignal and a hard caller-side timeout. + * + * The explicit race means a non-compliant downstream adapter cannot hold a list + * response open forever even if it ignores the supplied AbortSignal. + * + * @param {() => Promise} lookup - Deferred downstream lookup. + * @param {AbortController} controller - Controller whose signal is passed downstream. + * @param {number} timeoutMs - Hard timeout in milliseconds. + * @returns {Promise} The downstream status. + */ +async function withTimeout(lookup, controller, timeoutMs) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new Error('attachment status lookup timed out')); + }, timeoutMs); + }); + try { + return await Promise.race([lookup(), timeout]); + } finally { + clearTimeout(timer); + } +} + +/** + * Refresh pending attachment statuses through a bounded worker pool. + * + * Rows are updated in place so the caller can serialize the refreshed public + * representation. Missing job identifiers and downstream or persistence + * failures preserve stale status and never fail the attachment-list response. + * + * @param {Array} rows - Attachment rows containing `id`, `status`, and `jobId`. + * @param {object} options - Downstream functions, tenant identifiers, limits, and metrics. + * @param {number|string} options.orgId - ScopeWeave organization identifier. + * @param {number|string} options.userId - Requesting user identifier. + * @param {(orgId: unknown, userId: unknown, jobId: string, options: {signal: AbortSignal}) => Promise} options.jobStatus - Downstream lookup. + * @param {(status: string, attachmentId: unknown) => unknown|Promise} options.updateStatus - Changed-only persistence callback. + * @param {unknown} [options.concurrency] - Maximum concurrent lookups. + * @param {unknown} [options.timeoutMs] - Per-lookup timeout in milliseconds. + * @param {object} [options.metrics] - Mutable process metrics object. + * @returns {Promise<{attempted:number,changed:number,failed:number,deferred:number}>} Structured counters. + */ +export async function refreshAttachmentStatuses(rows, options) { + if (!Array.isArray(rows)) throw new TypeError('rows must be an array'); + if (typeof options?.jobStatus !== 'function') throw new TypeError('jobStatus must be a function'); + if (typeof options?.updateStatus !== 'function') throw new TypeError('updateStatus must be a function'); + + const counts = { attempted: 0, changed: 0, failed: 0, deferred: 0 }; + const pending = rows.filter((row) => row?.status === 'PENDING' || row?.status === 'RUNNING'); + const concurrency = normalizeAttachmentStatusConcurrency(options.concurrency); + const timeoutMs = normalizeAttachmentStatusTimeoutMs(options.timeoutMs); + let cursor = 0; + + /** + * Process pending rows until the shared cursor is exhausted. + * + * JavaScript advances the cursor synchronously before each await, so workers + * claim distinct rows without locks and context switching stays bounded by the + * configured worker count. + * + * @returns {Promise} Resolves after this worker has no remaining row. + */ + async function worker() { + for (;;) { + const index = cursor; + cursor += 1; + if (index >= pending.length) return; + const row = pending[index]; + const jobId = typeof row.jobId === 'string' ? row.jobId.trim() : ''; + if (!jobId) { + counts.deferred += 1; + continue; + } + + counts.attempted += 1; + const controller = new AbortController(); + try { + const nextStatus = await withTimeout( + () => options.jobStatus( + options.orgId, + options.userId, + jobId, + { signal: controller.signal }, + ), + controller, + timeoutMs, + ); + if (!ATTACHMENT_STATUS_VALUES.has(nextStatus)) { + throw new Error('invalid downstream status'); + } + if (nextStatus !== row.status) { + await options.updateStatus(nextStatus, row.id); + row.status = nextStatus; + counts.changed += 1; + } + } catch { + counts.failed += 1; + } + } + } + + const workerCount = Math.min(concurrency, pending.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + addRefreshMetrics(options.metrics, counts); + return counts; +} From 9f37f793c20a5e8a6f5fad9d35200436dc9e7455 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 16:01:05 +0900 Subject: [PATCH 17/45] ci: make attachment repair atomic and marker-based --- .../repair-attachment-status-refresh.yml | 473 +++++++----------- 1 file changed, 185 insertions(+), 288 deletions(-) diff --git a/.github/workflows/repair-attachment-status-refresh.yml b/.github/workflows/repair-attachment-status-refresh.yml index 7e7c56d9..3d509692 100644 --- a/.github/workflows/repair-attachment-status-refresh.yml +++ b/.github/workflows/repair-attachment-status-refresh.yml @@ -31,248 +31,106 @@ jobs: node-version: 22.13.0 cache: npm - - name: Apply bounded refresh implementation and regression tests + - name: Materialize the complete bounded-refresh implementation shell: bash run: | set -euo pipefail python3 <<'PY' from pathlib import Path + from textwrap import dedent import json - def replace_once(path, old, new): - file_path = Path(path) - text = file_path.read_text(encoding='utf-8') - count = text.count(old) - if count != 1: - raise SystemExit(f'{path}: expected one replacement target, found {count}') - file_path.write_text(text.replace(old, new, 1), encoding='utf-8') - - attachment_status = r'''/** Default maximum concurrent Clearfolio status lookups. */ - export const ATTACHMENT_STATUS_DEFAULT_CONCURRENCY = 8; - - /** Conservative hard ceiling for operator-configured lookup concurrency. */ - export const ATTACHMENT_STATUS_MAX_CONCURRENCY = 32; - - /** Default downstream status lookup timeout in milliseconds. */ - export const ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS = 3_000; - - /** Hard ceiling for a downstream status lookup timeout in milliseconds. */ - export const ATTACHMENT_STATUS_MAX_TIMEOUT_MS = 30_000; - - /** - * Normalize a positive integer while applying a conservative upper bound. - * - * @param {unknown} value - Untrusted environment or caller value. - * @param {number} fallback - Value used for missing or invalid input. - * @param {number} maximum - Largest accepted value. - * @returns {number} A safe positive integer no greater than `maximum`. - */ - function normalizeBoundedInteger(value, fallback, maximum) { - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1) return fallback; - return Math.min(parsed, maximum); - } - - /** - * Normalize the configured attachment-status worker count. - * - * @param {unknown} value - Environment or caller supplied value. - * @returns {number} An integer between 1 and 32, defaulting to 8. - */ - export function normalizeAttachmentStatusConcurrency(value) { - return normalizeBoundedInteger( - value, - ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, - ATTACHMENT_STATUS_MAX_CONCURRENCY, - ); - } - - /** - * Normalize the configured Clearfolio status timeout. - * - * @param {unknown} value - Environment or caller supplied value. - * @returns {number} A positive timeout no greater than 30 seconds. - */ - export function normalizeAttachmentStatusTimeoutMs(value) { - return normalizeBoundedInteger( - value, - ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, - ATTACHMENT_STATUS_MAX_TIMEOUT_MS, - ); - } - - /** - * Add one refresh result to process-level operational counters. - * - * @param {object|undefined} metrics - Mutable process metric registry. - * @param {{attempted:number,changed:number,failed:number,deferred:number}} counts - Refresh result. - * @returns {void} - */ - function addRefreshMetrics(metrics, counts) { - if (!metrics) return; - const fields = { - attachmentStatusRefreshAttempted: 'attempted', - attachmentStatusRefreshChanged: 'changed', - attachmentStatusRefreshFailed: 'failed', - attachmentStatusRefreshDeferred: 'deferred', - }; - for (const [metric, count] of Object.entries(fields)) { - metrics[metric] = (Number(metrics[metric]) || 0) + counts[count]; - } - } - - /** - * Refresh pending attachment statuses through a bounded worker pool. - * - * Rows are updated in place so the caller can serialize the refreshed public - * representation. Missing job identifiers and downstream or persistence - * failures preserve stale status and never fail the attachment-list response. - * - * @param {Array} rows - Attachment rows containing `id`, `status`, and `jobId`. - * @param {object} options - Downstream functions, tenant identifiers, limits, and metrics. - * @param {number|string} options.orgId - ScopeWeave organization identifier. - * @param {number|string} options.userId - Requesting user identifier. - * @param {(orgId: unknown, userId: unknown, jobId: string, options: {signal: AbortSignal}) => Promise} options.jobStatus - Downstream lookup. - * @param {(status: string, attachmentId: unknown) => unknown|Promise} options.updateStatus - Changed-only persistence callback. - * @param {unknown} [options.concurrency] - Maximum concurrent lookups. - * @param {unknown} [options.timeoutMs] - Per-lookup timeout in milliseconds. - * @param {object} [options.metrics] - Mutable process metrics object. - * @returns {Promise<{attempted:number,changed:number,failed:number,deferred:number}>} Structured counters. - */ - export async function refreshAttachmentStatuses(rows, options) { - if (!Array.isArray(rows)) throw new TypeError('rows must be an array'); - if (typeof options?.jobStatus !== 'function') throw new TypeError('jobStatus must be a function'); - if (typeof options?.updateStatus !== 'function') throw new TypeError('updateStatus must be a function'); - - const counts = { attempted: 0, changed: 0, failed: 0, deferred: 0 }; - const pending = rows.filter((row) => row?.status === 'PENDING' || row?.status === 'RUNNING'); - const concurrency = normalizeAttachmentStatusConcurrency(options.concurrency); - const timeoutMs = normalizeAttachmentStatusTimeoutMs(options.timeoutMs); - let cursor = 0; - - async function worker() { - for (;;) { - const index = cursor; - cursor += 1; - if (index >= pending.length) return; - const row = pending[index]; - const jobId = typeof row.jobId === 'string' ? row.jobId.trim() : ''; - if (!jobId) { - counts.deferred += 1; - continue; - } - - counts.attempted += 1; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const nextStatus = await options.jobStatus( - options.orgId, - options.userId, - jobId, - { signal: controller.signal }, - ); - if (typeof nextStatus !== 'string' || !nextStatus.trim()) { - throw new Error('invalid downstream status'); - } - if (nextStatus !== row.status) { - await options.updateStatus(nextStatus, row.id); - row.status = nextStatus; - counts.changed += 1; - } - } catch { - counts.failed += 1; - } finally { - clearTimeout(timer); - } - } - } - - const workerCount = Math.min(concurrency, pending.length); - await Promise.all(Array.from({ length: workerCount }, () => worker())); - addRefreshMetrics(options.metrics, counts); - return counts; - } - '''.strip() + '\n' - Path('server/attachment_status.mjs').write_text(attachment_status, encoding='utf-8') - - replace_once( - 'server/app.mjs', - "import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';\n", - "import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';\nimport { normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs';\n", - ) - - replace_once( - 'server/app.mjs', - "const metrics = { startedAt: new Date().toISOString(), requests: 0, s2xx: 0, s4xx: 0, s5xx: 0, signups: 0, projectsCreated: 0, webhookDeliveries: 0 };", - "const metrics = {\n startedAt: new Date().toISOString(),\n requests: 0,\n s2xx: 0,\n s4xx: 0,\n s5xx: 0,\n signups: 0,\n projectsCreated: 0,\n webhookDeliveries: 0,\n attachmentStatusRefreshAttempted: 0,\n attachmentStatusRefreshChanged: 0,\n attachmentStatusRefreshFailed: 0,\n attachmentStatusRefreshDeferred: 0,\n};", - ) - - replace_once( - 'server/app.mjs', - "const ATTACH_MAX_BYTES = 10 * 1024 * 1024;\n", - "const ATTACH_MAX_BYTES = 10 * 1024 * 1024;\nconst ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency(\n process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY,\n);\nconst ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs(\n process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS,\n);\nconst updateAttachmentStatusStatement = db.prepare(\n 'UPDATE attachments SET status = ? WHERE id = ?',\n);\n", - ) - - old_route = r''' const rows = (taskId - ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) - : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); - // PENDING 잡 상태 갱신(최선 노력). Concurrent, but bounded so a large - // attachment list cannot open unbounded simultaneous Clearfolio calls. - // ponytail: fixed chunk size 5; make it configurable only if rate limits bite. - const pending = rows.filter((r) => r.status === 'PENDING' || r.status === 'RUNNING'); - for (let i = 0; i < pending.length; i += 5) { - await Promise.all(pending.slice(i, i + 5).map(async (r) => { - try { - const jid = db.prepare('SELECT job_id FROM attachments WHERE id = ?').get(r.id).job_id; - const st = await jobStatus(p.org_id, uid, jid); - if (st !== r.status) { - db.prepare('UPDATE attachments SET status = ? WHERE id = ?').run(st, r.id); - r.status = st; - } - } catch { /* keep stale status */ } - })); - } - return c.json({ attachments: rows });''' - new_route = r''' const rows = (taskId - ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) - : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); - await refreshAttachmentStatuses(rows, { - orgId: p.org_id, - userId: uid, - jobStatus, - updateStatus: (status, attachmentId) => updateAttachmentStatusStatement.run(status, attachmentId), - concurrency: ATTACH_STATUS_CONCURRENCY, - timeoutMs: ATTACH_STATUS_TIMEOUT_MS, - metrics, - }); - const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); - return c.json({ attachments });''' - replace_once('server/app.mjs', old_route, new_route) - - old_job_status = r'''export async function jobStatus(orgId, userId, jobId) { - if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; - const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { - headers: tenantHeaders(orgId, userId), + app_path = Path('server/app.mjs') + app = app_path.read_text(encoding='utf-8') + + clearfolio_import = "import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';\n" + refresh_import = "import { normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs';\n" + if refresh_import not in app: + if app.count(clearfolio_import) != 1: + raise SystemExit('clearfolio import marker is not unique') + app = app.replace(clearfolio_import, clearfolio_import + refresh_import, 1) + + metrics_old = "const metrics = { startedAt: new Date().toISOString(), requests: 0, s2xx: 0, s4xx: 0, s5xx: 0, signups: 0, projectsCreated: 0, webhookDeliveries: 0 };" + metrics_new = dedent(''' + const metrics = { + startedAt: new Date().toISOString(), + requests: 0, + s2xx: 0, + s4xx: 0, + s5xx: 0, + signups: 0, + projectsCreated: 0, + webhookDeliveries: 0, + attachmentStatusRefreshAttempted: 0, + attachmentStatusRefreshChanged: 0, + attachmentStatusRefreshFailed: 0, + attachmentStatusRefreshDeferred: 0, + }; + ''').strip() + if 'attachmentStatusRefreshAttempted' not in app: + if app.count(metrics_old) != 1: + raise SystemExit('metrics marker is not unique') + app = app.replace(metrics_old, metrics_new, 1) + + constant_marker = 'const ATTACH_MAX_BYTES = 10 * 1024 * 1024;\n' + constant_block = dedent(''' + const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, + ); + const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, + ); + const updateAttachmentStatusStatement = db.prepare( + 'UPDATE attachments SET status = ? WHERE id = ?', + ); + ''') + if 'const ATTACH_STATUS_CONCURRENCY' not in app: + if app.count(constant_marker) != 1: + raise SystemExit('attachment constant marker is not unique') + app = app.replace(constant_marker, constant_marker + constant_block, 1) + + route_marker = "app.get('/api/projects/:id/attachments', requireAuth, async (c) => {" + route_start = app.index(route_marker) + body_start = app.index(" const taskId = c.req.query('taskId');", route_start) + body_end_marker = ' return c.json({ attachments: rows });' + body_end = app.index(body_end_marker, body_start) + len(body_end_marker) + route_body = dedent(''' + const taskId = c.req.query('taskId'); + const rows = (taskId + ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) + : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); + await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => + updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + metrics, }); - const data = await res.json().catch(() => ({})); - return data.status || 'FAILED'; - }''' - new_job_status = r'''/** - * Read a Clearfolio conversion status, optionally using a caller-owned abort signal. + const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); + return c.json({ attachments }); + ''').rstrip() + app = app[:body_start] + route_body + app[body_end:] + app_path.write_text(app, encoding='utf-8') + + clearfolio_path = Path('server/clearfolio.mjs') + clearfolio = clearfolio_path.read_text(encoding='utf-8') + status_start = clearfolio.index('export async function jobStatus(') + status_end = clearfolio.index('// SUCCEEDED', status_start) + status_function = dedent(''' + /** + * Read a Clearfolio conversion status with optional caller cancellation. * - * @param {number|string} orgId - ScopeWeave organization identifier. - * @param {number|string} userId - Requesting user identifier. + * @param {string|number} orgId - ScopeWeave organization identifier. + * @param {string|number} userId - Requesting user identifier. * @param {string} jobId - Clearfolio conversion job identifier. - * @param {{signal?: AbortSignal}} [options] - Optional request cancellation signal. + * @param {{signal?:AbortSignal}} [options] - Optional request cancellation signal. * @returns {Promise} Downstream conversion status. */ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { @@ -283,10 +141,14 @@ jobs: }); const data = await res.json().catch(() => ({})); return data.status || 'FAILED'; - }''' - replace_once('server/clearfolio.mjs', old_job_status, new_job_status) + } - unit_test = r'''import test from 'node:test'; + ''') + clearfolio = clearfolio[:status_start] + status_function + clearfolio[status_end:] + clearfolio_path.write_text(clearfolio, encoding='utf-8') + + unit_test = dedent(r''' + import test from 'node:test'; import assert from 'node:assert/strict'; import { ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, @@ -312,25 +174,30 @@ jobs: test('refresh validates its dependency contract', async () => { await assert.rejects(() => refreshAttachmentStatuses(null, {}), /rows must be an array/); + await assert.rejects(() => refreshAttachmentStatuses([], undefined), /jobStatus must be a function/); await assert.rejects(() => refreshAttachmentStatuses([], { updateStatus() {} }), /jobStatus must be a function/); await assert.rejects(() => refreshAttachmentStatuses([], { jobStatus() {} }), /updateStatus must be a function/); }); test('empty and settled rows perform no downstream work', async () => { + const dependencies = { + jobStatus: async () => { throw new Error('must not run'); }, + updateStatus: () => { throw new Error('must not run'); }, + }; + assert.deepEqual(await refreshAttachmentStatuses([], dependencies), { + attempted: 0, changed: 0, failed: 0, deferred: 0, + }); const metrics = {}; - const counts = await refreshAttachmentStatuses( - [{ id: 1, status: 'SUCCEEDED', jobId: 'job-1' }], - { - jobStatus: async () => { throw new Error('must not run'); }, - updateStatus: () => { throw new Error('must not run'); }, - metrics, - }, + assert.deepEqual( + await refreshAttachmentStatuses([null, { id: 1, status: 'SUCCEEDED', jobId: 'job-1' }], { ...dependencies, metrics }), + { attempted: 0, changed: 0, failed: 0, deferred: 0 }, ); - assert.deepEqual(counts, { attempted: 0, changed: 0, failed: 0, deferred: 0 }); - assert.equal(metrics.attachmentStatusRefreshAttempted, 0); - assert.equal(metrics.attachmentStatusRefreshChanged, 0); - assert.equal(metrics.attachmentStatusRefreshFailed, 0); - assert.equal(metrics.attachmentStatusRefreshDeferred, 0); + assert.deepEqual(metrics, { + attachmentStatusRefreshAttempted: 0, + attachmentStatusRefreshChanged: 0, + attachmentStatusRefreshFailed: 0, + attachmentStatusRefreshDeferred: 0, + }); }); test('100 pending rows respect configured concurrency and persist only changes', async () => { @@ -360,20 +227,22 @@ jobs: assert.equal(signal.aborted, false); active += 1; peak = Math.max(peak, active); - await new Promise((resolve) => setTimeout(resolve, Number(jobId.split('-')[1]) % 3)); + const rowNumber = Number(jobId.split('-')[1]); + await new Promise((resolve) => setTimeout(resolve, rowNumber % 3)); active -= 1; - const row = rows[Number(jobId.split('-')[1]) - 1]; - return Number(jobId.split('-')[1]) % 2 === 0 ? 'SUCCEEDED' : row.status; + return rowNumber % 2 === 0 ? 'SUCCEEDED' : rows[rowNumber - 1].status; }, updateStatus: async (status, attachmentId) => updates.push([status, attachmentId]), }); assert.ok(peak <= 8, `peak concurrency ${peak} exceeded configured limit`); assert.deepEqual(counts, { attempted: 100, changed: 50, failed: 0, deferred: 0 }); assert.equal(updates.length, 50); - assert.equal(metrics.attachmentStatusRefreshAttempted, 110); - assert.equal(metrics.attachmentStatusRefreshChanged, 70); - assert.equal(metrics.attachmentStatusRefreshFailed, 30); - assert.equal(metrics.attachmentStatusRefreshDeferred, 40); + assert.deepEqual(metrics, { + attachmentStatusRefreshAttempted: 110, + attachmentStatusRefreshChanged: 70, + attachmentStatusRefreshFailed: 30, + attachmentStatusRefreshDeferred: 40, + }); }); test('invalid identifiers and downstream, timeout, or write failures remain isolated', async () => { @@ -392,13 +261,10 @@ jobs: timeoutMs: 5, jobStatus: async (_orgId, _userId, jobId, { signal }) => { if (jobId === 'throws') throw new Error('downstream failure'); - if (jobId === 'invalid-status') return ''; + if (jobId === 'invalid-status') return 'UNKNOWN'; if (jobId === 'write-fails') return 'SUCCEEDED'; - return new Promise((resolve, reject) => { - signal.addEventListener('abort', () => { - aborted = true; - reject(new Error('aborted')); - }, { once: true }); + return new Promise(() => { + signal.addEventListener('abort', () => { aborted = true; }, { once: true }); }); }, updateStatus: () => { throw new Error('write failure'); }, @@ -408,10 +274,11 @@ jobs: assert.equal(rows[5].status, 'PENDING'); assert.equal(rows[6].status, 'PENDING'); }); - '''.strip() + '\n' + ''').lstrip() Path('tests/unit/attachment-status.test.mjs').write_text(unit_test, encoding='utf-8') - signal_test = r'''import test from 'node:test'; + signal_test = dedent(r''' + import test from 'node:test'; import assert from 'node:assert/strict'; test('Clearfolio jobStatus forwards the caller abort signal', async () => { @@ -433,10 +300,11 @@ jobs: delete process.env.CLEARFOLIO_URL; } }); - '''.strip() + '\n' + ''').lstrip() Path('tests/unit/clearfolio-status-signal.test.mjs').write_text(signal_test, encoding='utf-8') - api_test = r'''import test from 'node:test'; + api_test = dedent(r''' + import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -447,7 +315,6 @@ jobs: const { app } = await import('../../server/app.mjs'); const { db } = await import('../../server/db.mjs'); - const jsonRequest = (path, options = {}) => app.request(path, { ...options, headers: { 'content-type': 'application/json', ...(options.headers || {}) }, @@ -474,14 +341,12 @@ jobs: assert.equal(response.status, 200); const token = (await response.json()).token; const auth = { authorization: `Bearer ${token}` }; - response = await jsonRequest('/api/me', { headers: auth }); const userId = (await response.json()).user.id; response = await jsonRequest('/api/projects', { - method: 'POST', - headers: auth, - body: JSON.stringify({ name: 'Attachment Status Project' }), + method: 'POST', headers: auth, body: JSON.stringify({ name: 'Attachment Status Project' }), }); + assert.equal(response.status, 200); const projectId = (await response.json()).id; const first = await upload(projectId, token, 'task-a'); @@ -513,38 +378,55 @@ jobs: assert.equal(metrics.attachmentStatusRefreshFailed, 0); assert.equal(metrics.attachmentStatusRefreshDeferred, 1); + response = await jsonRequest('/api/metrics?format=prometheus'); + const prometheus = await response.text(); + assert.match(prometheus, /scopeweave_attachment_status_refresh_attempted 2/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_changed 2/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_failed 0/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_deferred 1/); + const source = readFileSync('server/app.mjs', 'utf8'); const routeStart = source.indexOf("app.get('/api/projects/:id/attachments'"); const routeEnd = source.indexOf('// 열람:', routeStart); const route = source.slice(routeStart, routeEnd); - assert.ok(routeStart >= 0 && routeEnd > routeStart, 'attachment list route is discoverable'); + assert.ok(routeStart >= 0 && routeEnd > routeStart); assert.equal((route.match(/a\.job_id AS jobId/g) || []).length, 2); assert.doesNotMatch(route, /SELECT job_id FROM attachments/); assert.match(route, /rows\.map\(\(\{ jobId: _internalJobId, \.\.\.publicRow \}\) => publicRow\)/); }); - '''.strip() + '\n' + ''').lstrip() Path('tests/api/attachment-status.test.mjs').write_text(api_test, encoding='utf-8') package_path = Path('package.json') package = json.loads(package_path.read_text(encoding='utf-8')) - package['scripts']['coverage'] = package['scripts']['coverage'].replace( - '--include=server/app.mjs', - '--include=server/attachment_status.mjs --include=server/app.mjs', - ) - package['scripts']['test:api'] += ' && node tests/api/attachment-status.test.mjs' - package['scripts']['test:unit'] += ' && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs' - package['scripts']['test:coverage'] = ( - 'node tests/unit/attachment-status.test.mjs && ' - + package['scripts']['test:coverage'] - + ' && node tests/api/attachment-status.test.mjs' - ) + if '--include=server/attachment_status.mjs' not in package['scripts']['coverage']: + package['scripts']['coverage'] = package['scripts']['coverage'].replace( + '--include=server/app.mjs', + '--include=server/attachment_status.mjs --include=server/app.mjs', + ) + if 'tests/api/attachment-status.test.mjs' not in package['scripts']['test:api']: + package['scripts']['test:api'] += ' && node tests/api/attachment-status.test.mjs' + if 'tests/unit/attachment-status.test.mjs' not in package['scripts']['test:unit']: + package['scripts']['test:unit'] += ' && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs' + if not package['scripts']['test:coverage'].startswith('node tests/unit/attachment-status.test.mjs'): + package['scripts']['test:coverage'] = 'node tests/unit/attachment-status.test.mjs && ' + package['scripts']['test:coverage'] package_path.write_text(json.dumps(package, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') - replace_once( - 'CHANGELOG.md', - '### Changed\n\n', - '### Changed\n\n- Attachment status refresh now removes the per-row database lookup, uses a configurable bounded worker pool with abortable downstream timeouts, isolates partial failures, excludes internal conversion identifiers from list responses, and exports attempted/changed/failed/deferred operational metrics.\n', - ) + changelog_path = Path('CHANGELOG.md') + changelog = changelog_path.read_text(encoding='utf-8') + bullet = dedent(''' + - Attachment-list status refresh now removes the per-row database lookup, + uses a configurable bounded worker pool with abortable downstream timeouts, + preserves stale status after isolated failures, excludes internal conversion + identifiers from responses, and reports attempted, changed, failed, and + deferred refresh counters. + ''') + if 'Attachment-list status refresh now removes' not in changelog: + marker = '### Changed\n\n' + if changelog.count(marker) != 1: + raise SystemExit('changelog marker is not unique') + changelog = changelog.replace(marker, marker + bullet, 1) + changelog_path.write_text(changelog, encoding='utf-8') PY - name: Install dependencies @@ -560,10 +442,25 @@ jobs: npm run test:unit npm run test:api npm run coverage + node scripts/ci/static_coverage_evidence.mjs docstrings npm run test:e2e:cloud + python3 - <<'PY' + import json + from pathlib import Path + coverage = json.loads(Path('coverage/coverage-final.json').read_text()) + target = next(item for name, item in coverage.items() if name.replace('\\', '/').endswith('/server/attachment_status.mjs')) + checks = { + 'statements': list(target['s'].values()), + 'functions': list(target['f'].values()), + 'branches': [hit for hits in target['b'].values() for hit in hits], + } + for label, hits in checks.items(): + if not hits or any(hit == 0 for hit in hits): + raise SystemExit(f'attachment_status.mjs lacks 100% {label} coverage') + PY git diff --check - - name: Commit verified repair and remove one-shot workflow + - name: Commit only the verified implementation shell: bash run: | set -euo pipefail From 8fdffb1d9fba85421a3303d8850aa8c2044410c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 16:06:39 +0900 Subject: [PATCH 18/45] ci: patch attachment repair browser setup --- .../patch-attachment-repair-browser.yml | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/patch-attachment-repair-browser.yml diff --git a/.github/workflows/patch-attachment-repair-browser.yml b/.github/workflows/patch-attachment-repair-browser.yml new file mode 100644 index 00000000..89ac7ec1 --- /dev/null +++ b/.github/workflows/patch-attachment-repair-browser.yml @@ -0,0 +1,49 @@ +name: Patch attachment repair browser setup + +on: + push: + branches: + - jules-promise-all-attachments-7315426299343398085 + +permissions: + contents: write + +concurrency: + group: patch-attachment-repair-browser + cancel-in-progress: true + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: jules-promise-all-attachments-7315426299343398085 + fetch-depth: 0 + persist-credentials: true + + - name: Add Playwright browser installation and remove bootstrap + shell: bash + run: | + set -euo pipefail + python3 <<'PY' + from pathlib import Path + + path = Path('.github/workflows/repair-attachment-status-refresh.yml') + text = path.read_text(encoding='utf-8') + target = ' npm run test:e2e:cloud\n' + replacement = ' npx playwright install --with-deps chromium\n' + target + if 'npx playwright install --with-deps chromium' not in text: + if text.count(target) != 1: + raise SystemExit(f'expected one cloud E2E command, found {text.count(target)}') + path.write_text(text.replace(target, replacement, 1), encoding='utf-8') + PY + rm .github/workflows/patch-attachment-repair-browser.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m 'ci: install Chromium for attachment repair validation' + git push origin HEAD:jules-promise-all-attachments-7315426299343398085 From a0e53d340692125e1506a420aa80098d638237bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 16:09:24 +0900 Subject: [PATCH 19/45] test(e2e): install Chromium for cloud validation --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9258d65f..90b19ba8 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test:coverage": "node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", - "test:e2e:cloud": "playwright test tests/e2e/cloud.spec.js", + "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", "test:fuzz": "playwright install chromium && playwright test tests/e2e/csv_formula_fuzz.spec.js", "fuzz": "node --test tests/fuzz/*.mjs" }, From 0caf4eb8cd71110d5c3f0a49081397eb7cc00d33 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 16:10:03 +0900 Subject: [PATCH 20/45] ci: remove browser bootstrap workflow --- .../patch-attachment-repair-browser.yml | 49 ------------------- 1 file changed, 49 deletions(-) delete mode 100644 .github/workflows/patch-attachment-repair-browser.yml diff --git a/.github/workflows/patch-attachment-repair-browser.yml b/.github/workflows/patch-attachment-repair-browser.yml deleted file mode 100644 index 89ac7ec1..00000000 --- a/.github/workflows/patch-attachment-repair-browser.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Patch attachment repair browser setup - -on: - push: - branches: - - jules-promise-all-attachments-7315426299343398085 - -permissions: - contents: write - -concurrency: - group: patch-attachment-repair-browser - cancel-in-progress: true - -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: jules-promise-all-attachments-7315426299343398085 - fetch-depth: 0 - persist-credentials: true - - - name: Add Playwright browser installation and remove bootstrap - shell: bash - run: | - set -euo pipefail - python3 <<'PY' - from pathlib import Path - - path = Path('.github/workflows/repair-attachment-status-refresh.yml') - text = path.read_text(encoding='utf-8') - target = ' npm run test:e2e:cloud\n' - replacement = ' npx playwright install --with-deps chromium\n' + target - if 'npx playwright install --with-deps chromium' not in text: - if text.count(target) != 1: - raise SystemExit(f'expected one cloud E2E command, found {text.count(target)}') - path.write_text(text.replace(target, replacement, 1), encoding='utf-8') - PY - rm .github/workflows/patch-attachment-repair-browser.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git commit -m 'ci: install Chromium for attachment repair validation' - git push origin HEAD:jules-promise-all-attachments-7315426299343398085 From 0e3cd259aab068733c4edadba4db7f802318c16a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:12:41 +0000 Subject: [PATCH 21/45] perf(attachments): bound status refresh and remove N+1 queries --- .../repair-attachment-status-refresh.yml | 472 ------------------ CHANGELOG.md | 6 + package.json | 8 +- server/app.mjs | 72 ++- server/clearfolio.mjs | 13 +- tests/api/attachment-status.test.mjs | 90 ++++ tests/unit/attachment-status.test.mjs | 126 +++++ tests/unit/clearfolio-status-signal.test.mjs | 22 + 8 files changed, 306 insertions(+), 503 deletions(-) delete mode 100644 .github/workflows/repair-attachment-status-refresh.yml create mode 100644 tests/api/attachment-status.test.mjs create mode 100644 tests/unit/attachment-status.test.mjs create mode 100644 tests/unit/clearfolio-status-signal.test.mjs diff --git a/.github/workflows/repair-attachment-status-refresh.yml b/.github/workflows/repair-attachment-status-refresh.yml deleted file mode 100644 index 3d509692..00000000 --- a/.github/workflows/repair-attachment-status-refresh.yml +++ /dev/null @@ -1,472 +0,0 @@ -name: Repair attachment status refresh - -on: - pull_request: - branches: [develop] - types: [ready_for_review] - -permissions: - contents: write - -concurrency: - group: repair-attachment-status-refresh-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - repair: - if: github.event.pull_request.number == 420 && github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: jules-promise-all-attachments-7315426299343398085 - fetch-depth: 0 - persist-credentials: true - - - name: Set up Node.js - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - with: - node-version: 22.13.0 - cache: npm - - - name: Materialize the complete bounded-refresh implementation - shell: bash - run: | - set -euo pipefail - python3 <<'PY' - from pathlib import Path - from textwrap import dedent - import json - - app_path = Path('server/app.mjs') - app = app_path.read_text(encoding='utf-8') - - clearfolio_import = "import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';\n" - refresh_import = "import { normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs';\n" - if refresh_import not in app: - if app.count(clearfolio_import) != 1: - raise SystemExit('clearfolio import marker is not unique') - app = app.replace(clearfolio_import, clearfolio_import + refresh_import, 1) - - metrics_old = "const metrics = { startedAt: new Date().toISOString(), requests: 0, s2xx: 0, s4xx: 0, s5xx: 0, signups: 0, projectsCreated: 0, webhookDeliveries: 0 };" - metrics_new = dedent(''' - const metrics = { - startedAt: new Date().toISOString(), - requests: 0, - s2xx: 0, - s4xx: 0, - s5xx: 0, - signups: 0, - projectsCreated: 0, - webhookDeliveries: 0, - attachmentStatusRefreshAttempted: 0, - attachmentStatusRefreshChanged: 0, - attachmentStatusRefreshFailed: 0, - attachmentStatusRefreshDeferred: 0, - }; - ''').strip() - if 'attachmentStatusRefreshAttempted' not in app: - if app.count(metrics_old) != 1: - raise SystemExit('metrics marker is not unique') - app = app.replace(metrics_old, metrics_new, 1) - - constant_marker = 'const ATTACH_MAX_BYTES = 10 * 1024 * 1024;\n' - constant_block = dedent(''' - const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, - ); - const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, - ); - const updateAttachmentStatusStatement = db.prepare( - 'UPDATE attachments SET status = ? WHERE id = ?', - ); - ''') - if 'const ATTACH_STATUS_CONCURRENCY' not in app: - if app.count(constant_marker) != 1: - raise SystemExit('attachment constant marker is not unique') - app = app.replace(constant_marker, constant_marker + constant_block, 1) - - route_marker = "app.get('/api/projects/:id/attachments', requireAuth, async (c) => {" - route_start = app.index(route_marker) - body_start = app.index(" const taskId = c.req.query('taskId');", route_start) - body_end_marker = ' return c.json({ attachments: rows });' - body_end = app.index(body_end_marker, body_start) + len(body_end_marker) - route_body = dedent(''' - const taskId = c.req.query('taskId'); - const rows = (taskId - ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) - : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); - await refreshAttachmentStatuses(rows, { - orgId: p.org_id, - userId: uid, - jobStatus, - updateStatus: (status, attachmentId) => - updateAttachmentStatusStatement.run(status, attachmentId), - concurrency: ATTACH_STATUS_CONCURRENCY, - timeoutMs: ATTACH_STATUS_TIMEOUT_MS, - metrics, - }); - const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); - return c.json({ attachments }); - ''').rstrip() - app = app[:body_start] + route_body + app[body_end:] - app_path.write_text(app, encoding='utf-8') - - clearfolio_path = Path('server/clearfolio.mjs') - clearfolio = clearfolio_path.read_text(encoding='utf-8') - status_start = clearfolio.index('export async function jobStatus(') - status_end = clearfolio.index('// SUCCEEDED', status_start) - status_function = dedent(''' - /** - * Read a Clearfolio conversion status with optional caller cancellation. - * - * @param {string|number} orgId - ScopeWeave organization identifier. - * @param {string|number} userId - Requesting user identifier. - * @param {string} jobId - Clearfolio conversion job identifier. - * @param {{signal?:AbortSignal}} [options] - Optional request cancellation signal. - * @returns {Promise} Downstream conversion status. - */ - export async function jobStatus(orgId, userId, jobId, { signal } = {}) { - if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; - const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { - headers: tenantHeaders(orgId, userId), - signal, - }); - const data = await res.json().catch(() => ({})); - return data.status || 'FAILED'; - } - - ''') - clearfolio = clearfolio[:status_start] + status_function + clearfolio[status_end:] - clearfolio_path.write_text(clearfolio, encoding='utf-8') - - unit_test = dedent(r''' - import test from 'node:test'; - import assert from 'node:assert/strict'; - import { - ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, - ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, - ATTACHMENT_STATUS_MAX_CONCURRENCY, - ATTACHMENT_STATUS_MAX_TIMEOUT_MS, - normalizeAttachmentStatusConcurrency, - normalizeAttachmentStatusTimeoutMs, - refreshAttachmentStatuses, - } from '../../server/attachment_status.mjs'; - - test('attachment status configuration is bounded and fail-safe', () => { - assert.equal(normalizeAttachmentStatusConcurrency(undefined), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); - assert.equal(normalizeAttachmentStatusConcurrency('4'), 4); - assert.equal(normalizeAttachmentStatusConcurrency(0), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); - assert.equal(normalizeAttachmentStatusConcurrency(1.5), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); - assert.equal(normalizeAttachmentStatusConcurrency(999), ATTACHMENT_STATUS_MAX_CONCURRENCY); - assert.equal(normalizeAttachmentStatusTimeoutMs(undefined), ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS); - assert.equal(normalizeAttachmentStatusTimeoutMs('25'), 25); - assert.equal(normalizeAttachmentStatusTimeoutMs(-1), ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS); - assert.equal(normalizeAttachmentStatusTimeoutMs(50_000), ATTACHMENT_STATUS_MAX_TIMEOUT_MS); - }); - - test('refresh validates its dependency contract', async () => { - await assert.rejects(() => refreshAttachmentStatuses(null, {}), /rows must be an array/); - await assert.rejects(() => refreshAttachmentStatuses([], undefined), /jobStatus must be a function/); - await assert.rejects(() => refreshAttachmentStatuses([], { updateStatus() {} }), /jobStatus must be a function/); - await assert.rejects(() => refreshAttachmentStatuses([], { jobStatus() {} }), /updateStatus must be a function/); - }); - - test('empty and settled rows perform no downstream work', async () => { - const dependencies = { - jobStatus: async () => { throw new Error('must not run'); }, - updateStatus: () => { throw new Error('must not run'); }, - }; - assert.deepEqual(await refreshAttachmentStatuses([], dependencies), { - attempted: 0, changed: 0, failed: 0, deferred: 0, - }); - const metrics = {}; - assert.deepEqual( - await refreshAttachmentStatuses([null, { id: 1, status: 'SUCCEEDED', jobId: 'job-1' }], { ...dependencies, metrics }), - { attempted: 0, changed: 0, failed: 0, deferred: 0 }, - ); - assert.deepEqual(metrics, { - attachmentStatusRefreshAttempted: 0, - attachmentStatusRefreshChanged: 0, - attachmentStatusRefreshFailed: 0, - attachmentStatusRefreshDeferred: 0, - }); - }); - - test('100 pending rows respect configured concurrency and persist only changes', async () => { - const rows = Array.from({ length: 100 }, (_, index) => ({ - id: index + 1, - jobId: `job-${index + 1}`, - status: index % 3 === 0 ? 'RUNNING' : 'PENDING', - })); - let active = 0; - let peak = 0; - const updates = []; - const metrics = { - attachmentStatusRefreshAttempted: 10, - attachmentStatusRefreshChanged: 20, - attachmentStatusRefreshFailed: 30, - attachmentStatusRefreshDeferred: 40, - }; - const counts = await refreshAttachmentStatuses(rows, { - orgId: 7, - userId: 9, - concurrency: 8, - timeoutMs: 1_000, - metrics, - jobStatus: async (orgId, userId, jobId, { signal }) => { - assert.equal(orgId, 7); - assert.equal(userId, 9); - assert.equal(signal.aborted, false); - active += 1; - peak = Math.max(peak, active); - const rowNumber = Number(jobId.split('-')[1]); - await new Promise((resolve) => setTimeout(resolve, rowNumber % 3)); - active -= 1; - return rowNumber % 2 === 0 ? 'SUCCEEDED' : rows[rowNumber - 1].status; - }, - updateStatus: async (status, attachmentId) => updates.push([status, attachmentId]), - }); - assert.ok(peak <= 8, `peak concurrency ${peak} exceeded configured limit`); - assert.deepEqual(counts, { attempted: 100, changed: 50, failed: 0, deferred: 0 }); - assert.equal(updates.length, 50); - assert.deepEqual(metrics, { - attachmentStatusRefreshAttempted: 110, - attachmentStatusRefreshChanged: 70, - attachmentStatusRefreshFailed: 30, - attachmentStatusRefreshDeferred: 40, - }); - }); - - test('invalid identifiers and downstream, timeout, or write failures remain isolated', async () => { - const rows = [ - { id: 1, jobId: null, status: 'PENDING' }, - { id: 2, jobId: '', status: 'RUNNING' }, - { id: 3, jobId: ' ', status: 'PENDING' }, - { id: 4, jobId: 'throws', status: 'PENDING' }, - { id: 5, jobId: 'invalid-status', status: 'PENDING' }, - { id: 6, jobId: 'write-fails', status: 'PENDING' }, - { id: 7, jobId: 'times-out', status: 'PENDING' }, - ]; - let aborted = false; - const counts = await refreshAttachmentStatuses(rows, { - concurrency: 3, - timeoutMs: 5, - jobStatus: async (_orgId, _userId, jobId, { signal }) => { - if (jobId === 'throws') throw new Error('downstream failure'); - if (jobId === 'invalid-status') return 'UNKNOWN'; - if (jobId === 'write-fails') return 'SUCCEEDED'; - return new Promise(() => { - signal.addEventListener('abort', () => { aborted = true; }, { once: true }); - }); - }, - updateStatus: () => { throw new Error('write failure'); }, - }); - assert.equal(aborted, true); - assert.deepEqual(counts, { attempted: 4, changed: 0, failed: 4, deferred: 3 }); - assert.equal(rows[5].status, 'PENDING'); - assert.equal(rows[6].status, 'PENDING'); - }); - ''').lstrip() - Path('tests/unit/attachment-status.test.mjs').write_text(unit_test, encoding='utf-8') - - signal_test = dedent(r''' - import test from 'node:test'; - import assert from 'node:assert/strict'; - - test('Clearfolio jobStatus forwards the caller abort signal', async () => { - process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; - const originalFetch = globalThis.fetch; - let observedSignal; - globalThis.fetch = async (_url, options) => { - observedSignal = options.signal; - return { json: async () => ({ status: 'RUNNING' }) }; - }; - try { - const { jobStatus } = await import('../../server/clearfolio.mjs?status-signal-test=1'); - const controller = new AbortController(); - const status = await jobStatus(1, 2, 'job-1', { signal: controller.signal }); - assert.equal(status, 'RUNNING'); - assert.equal(observedSignal, controller.signal); - } finally { - globalThis.fetch = originalFetch; - delete process.env.CLEARFOLIO_URL; - } - }); - ''').lstrip() - Path('tests/unit/clearfolio-status-signal.test.mjs').write_text(signal_test, encoding='utf-8') - - api_test = dedent(r''' - import test from 'node:test'; - import assert from 'node:assert/strict'; - import { readFileSync } from 'node:fs'; - - process.env.SCOPEWEAVE_DB = ':memory:'; - process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY = '2'; - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS = '500'; - - const { app } = await import('../../server/app.mjs'); - const { db } = await import('../../server/db.mjs'); - const jsonRequest = (path, options = {}) => app.request(path, { - ...options, - headers: { 'content-type': 'application/json', ...(options.headers || {}) }, - }); - - async function upload(projectId, token, taskId) { - const form = new FormData(); - form.append('file', new Blob([`content-${taskId}`], { type: 'text/plain' }), `${taskId}.txt`); - form.set('taskId', taskId); - const response = await app.request(`/api/projects/${projectId}/attachments`, { - method: 'POST', - headers: { authorization: `Bearer ${token}` }, - body: form, - }); - assert.equal(response.status, 200); - return response.json(); - } - - test('attachment listing refreshes without N+1 queries or internal identifier leakage', async () => { - let response = await jsonRequest('/api/auth/signup', { - method: 'POST', - body: JSON.stringify({ email: 'attachments@scopeweave.test', password: 'password123', name: 'Attachments' }), - }); - assert.equal(response.status, 200); - const token = (await response.json()).token; - const auth = { authorization: `Bearer ${token}` }; - response = await jsonRequest('/api/me', { headers: auth }); - const userId = (await response.json()).user.id; - response = await jsonRequest('/api/projects', { - method: 'POST', headers: auth, body: JSON.stringify({ name: 'Attachment Status Project' }), - }); - assert.equal(response.status, 200); - const projectId = (await response.json()).id; - - const first = await upload(projectId, token, 'task-a'); - const second = await upload(projectId, token, 'task-b'); - db.prepare("UPDATE attachments SET status = 'PENDING' WHERE id IN (?, ?)").run(first.id, second.id); - db.prepare('INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)') - .run(projectId, 'task-missing', 'missing.txt', 'text/plain', 1, '', 'PENDING', userId); - - response = await jsonRequest(`/api/projects/${projectId}/attachments?taskId=task-a`, { headers: auth }); - assert.equal(response.status, 200); - let attachments = (await response.json()).attachments; - assert.equal(attachments.length, 1); - assert.equal(attachments[0].taskId, 'task-a'); - assert.equal(attachments[0].status, 'SUCCEEDED'); - assert.equal(Object.hasOwn(attachments[0], 'jobId'), false); - - response = await jsonRequest(`/api/projects/${projectId}/attachments`, { headers: auth }); - assert.equal(response.status, 200); - attachments = (await response.json()).attachments; - assert.equal(attachments.length, 3); - assert.equal(attachments.every((row) => !Object.hasOwn(row, 'jobId')), true); - assert.equal(attachments.find((row) => row.taskId === 'task-b').status, 'SUCCEEDED'); - assert.equal(attachments.find((row) => row.taskId === 'task-missing').status, 'PENDING'); - - response = await jsonRequest('/api/metrics'); - const metrics = await response.json(); - assert.equal(metrics.attachmentStatusRefreshAttempted, 2); - assert.equal(metrics.attachmentStatusRefreshChanged, 2); - assert.equal(metrics.attachmentStatusRefreshFailed, 0); - assert.equal(metrics.attachmentStatusRefreshDeferred, 1); - - response = await jsonRequest('/api/metrics?format=prometheus'); - const prometheus = await response.text(); - assert.match(prometheus, /scopeweave_attachment_status_refresh_attempted 2/); - assert.match(prometheus, /scopeweave_attachment_status_refresh_changed 2/); - assert.match(prometheus, /scopeweave_attachment_status_refresh_failed 0/); - assert.match(prometheus, /scopeweave_attachment_status_refresh_deferred 1/); - - const source = readFileSync('server/app.mjs', 'utf8'); - const routeStart = source.indexOf("app.get('/api/projects/:id/attachments'"); - const routeEnd = source.indexOf('// 열람:', routeStart); - const route = source.slice(routeStart, routeEnd); - assert.ok(routeStart >= 0 && routeEnd > routeStart); - assert.equal((route.match(/a\.job_id AS jobId/g) || []).length, 2); - assert.doesNotMatch(route, /SELECT job_id FROM attachments/); - assert.match(route, /rows\.map\(\(\{ jobId: _internalJobId, \.\.\.publicRow \}\) => publicRow\)/); - }); - ''').lstrip() - Path('tests/api/attachment-status.test.mjs').write_text(api_test, encoding='utf-8') - - package_path = Path('package.json') - package = json.loads(package_path.read_text(encoding='utf-8')) - if '--include=server/attachment_status.mjs' not in package['scripts']['coverage']: - package['scripts']['coverage'] = package['scripts']['coverage'].replace( - '--include=server/app.mjs', - '--include=server/attachment_status.mjs --include=server/app.mjs', - ) - if 'tests/api/attachment-status.test.mjs' not in package['scripts']['test:api']: - package['scripts']['test:api'] += ' && node tests/api/attachment-status.test.mjs' - if 'tests/unit/attachment-status.test.mjs' not in package['scripts']['test:unit']: - package['scripts']['test:unit'] += ' && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs' - if not package['scripts']['test:coverage'].startswith('node tests/unit/attachment-status.test.mjs'): - package['scripts']['test:coverage'] = 'node tests/unit/attachment-status.test.mjs && ' + package['scripts']['test:coverage'] - package_path.write_text(json.dumps(package, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') - - changelog_path = Path('CHANGELOG.md') - changelog = changelog_path.read_text(encoding='utf-8') - bullet = dedent(''' - - Attachment-list status refresh now removes the per-row database lookup, - uses a configurable bounded worker pool with abortable downstream timeouts, - preserves stale status after isolated failures, excludes internal conversion - identifiers from responses, and reports attempted, changed, failed, and - deferred refresh counters. - ''') - if 'Attachment-list status refresh now removes' not in changelog: - marker = '### Changed\n\n' - if changelog.count(marker) != 1: - raise SystemExit('changelog marker is not unique') - changelog = changelog.replace(marker, marker + bullet, 1) - changelog_path.write_text(changelog, encoding='utf-8') - PY - - - name: Install dependencies - run: npm ci - - - name: Run focused and full validation - shell: bash - run: | - set -euo pipefail - node tests/unit/attachment-status.test.mjs - node tests/unit/clearfolio-status-signal.test.mjs - node tests/api/attachment-status.test.mjs - npm run test:unit - npm run test:api - npm run coverage - node scripts/ci/static_coverage_evidence.mjs docstrings - npm run test:e2e:cloud - python3 - <<'PY' - import json - from pathlib import Path - coverage = json.loads(Path('coverage/coverage-final.json').read_text()) - target = next(item for name, item in coverage.items() if name.replace('\\', '/').endswith('/server/attachment_status.mjs')) - checks = { - 'statements': list(target['s'].values()), - 'functions': list(target['f'].values()), - 'branches': [hit for hits in target['b'].values() for hit in hits], - } - for label, hits in checks.items(): - if not hits or any(hit == 0 for hit in hits): - raise SystemExit(f'attachment_status.mjs lacks 100% {label} coverage') - PY - git diff --check - - - name: Commit only the verified implementation - shell: bash - run: | - set -euo pipefail - rm .github/workflows/repair-attachment-status-refresh.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git commit -m 'perf(attachments): bound status refresh and remove N+1 queries' - git push origin HEAD:jules-promise-all-attachments-7315426299343398085 diff --git a/CHANGELOG.md b/CHANGELOG.md index e84f41f8..b27ed2ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed + +- Attachment-list status refresh now removes the per-row database lookup, + uses a configurable bounded worker pool with abortable downstream timeouts, + preserves stale status after isolated failures, excludes internal conversion + identifiers from responses, and reports attempted, changed, failed, and + deferred refresh counters. - 프로젝트 이름 입력 필드에 입력 예시(placeholder)를 추가하여 사용자 편의성을 개선했습니다. - 데이터 테이블의 반복되는 액션 버튼에 컨텍스트 정보(작업명)를 포함한 명시적인 ARIA 레이블을 추가하고, 유효성 검사 에러를 폼 필드에 연결하여 접근성을 개선했습니다. - `createGanttBarElement`, `renderGantt`, `buildWeekdayTimeline`에서 반복적으로 호출되던 `compareDateStrings`를 직접적인 문자열 비교 연산(`>=`, `<=`)으로 교체하여 O(N*D) 복잡도의 캐시 스레싱과 정규식 검사를 방지했습니다. diff --git a/package.json b/package.json index 90b19ba8..7c25940d 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,11 @@ }, "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", - "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/app.mjs --include=server/auth.mjs --reporter=json --reporter=json-summary npm run test:coverage", + "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --reporter=json --reporter=json-summary npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs", - "test:coverage": "node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs", + "test:coverage": "node tests/unit/attachment-status.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", diff --git a/server/app.mjs b/server/app.mjs index affd87ea..685b8fb8 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -8,6 +8,7 @@ import { db, rowid } from './db.mjs'; import { hashPassword, verifyPassword, signToken, verifyToken, generateApiToken, hashApiToken } from './auth.mjs'; import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing.mjs'; import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs'; +import { normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs'; import { chat as orchestratorChat } from './orchestrator.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client @@ -73,7 +74,20 @@ function projectAccess(userId, projectId) { } // --- observability: in-process counters + structured request log. -const metrics = { startedAt: new Date().toISOString(), requests: 0, s2xx: 0, s4xx: 0, s5xx: 0, signups: 0, projectsCreated: 0, webhookDeliveries: 0 }; +const metrics = { + startedAt: new Date().toISOString(), + requests: 0, + s2xx: 0, + s4xx: 0, + s5xx: 0, + signups: 0, + projectsCreated: 0, + webhookDeliveries: 0, + attachmentStatusRefreshAttempted: 0, + attachmentStatusRefreshChanged: 0, + attachmentStatusRefreshFailed: 0, + attachmentStatusRefreshDeferred: 0, +}; // Outbound webhooks: POST signed JSON to each active hook subscribed to `event`. // Fire-and-forget with a timeout, one retry on failure, and a recorded outcome @@ -993,6 +1007,16 @@ app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { // 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio // 자격이 절대 노출되지 않음. const ATTACH_MAX_BYTES = 10 * 1024 * 1024; + +const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, +); +const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, +); +const updateAttachmentStatusStatement = db.prepare( + 'UPDATE attachments SET status = ? WHERE id = ?', +); app.post('/api/projects/:id/attachments', requireAuth, async (c) => { const uid = c.get('user').sub; const p = projectAccess(uid, c.req.param('id')); @@ -1022,31 +1046,27 @@ app.get('/api/projects/:id/attachments', requireAuth, async (c) => { const uid = c.get('user').sub; const p = projectAccess(uid, c.req.param('id')); if (!p) return c.json({ error: 'not found' }, 404); - const taskId = c.req.query('taskId'); - const rows = (taskId - ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) - : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); - // PENDING 잡 상태 갱신(최선 노력). Concurrent, but bounded so a large - // attachment list cannot open unbounded simultaneous Clearfolio calls. - // ponytail: fixed chunk size 5; make it configurable only if rate limits bite. - const pending = rows.filter((r) => r.status === 'PENDING' || r.status === 'RUNNING'); - for (let i = 0; i < pending.length; i += 5) { - await Promise.all(pending.slice(i, i + 5).map(async (r) => { - try { - const jid = db.prepare('SELECT job_id FROM attachments WHERE id = ?').get(r.id).job_id; - const st = await jobStatus(p.org_id, uid, jid); - if (st !== r.status) { - db.prepare('UPDATE attachments SET status = ? WHERE id = ?').run(st, r.id); - r.status = st; - } - } catch { /* keep stale status */ } - })); - } - return c.json({ attachments: rows }); + +const taskId = c.req.query('taskId'); +const rows = (taskId + ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) + : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); +await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => + updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + metrics, +}); +const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); +return c.json({ attachments }); }); // 열람: 서명 아티팩트 URL로 302. 새 탭 열기용으로 ?token=도 허용(ics/stream 패턴). diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index ae5cd8f3..29ccc048 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -56,10 +56,21 @@ export async function submitJob(orgId, userId, { name, mime, bytes }) { return { jobId: data.jobId, status: data.status || 'PENDING' }; } -export async function jobStatus(orgId, userId, jobId) { + +/** + * Read a Clearfolio conversion status with optional caller cancellation. + * + * @param {string|number} orgId - ScopeWeave organization identifier. + * @param {string|number} userId - Requesting user identifier. + * @param {string} jobId - Clearfolio conversion job identifier. + * @param {{signal?:AbortSignal}} [options] - Optional request cancellation signal. + * @returns {Promise} Downstream conversion status. + */ +export async function jobStatus(orgId, userId, jobId, { signal } = {}) { if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { headers: tenantHeaders(orgId, userId), + signal, }); const data = await res.json().catch(() => ({})); return data.status || 'FAILED'; diff --git a/tests/api/attachment-status.test.mjs b/tests/api/attachment-status.test.mjs new file mode 100644 index 00000000..c81eadf1 --- /dev/null +++ b/tests/api/attachment-status.test.mjs @@ -0,0 +1,90 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY = '2'; +process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS = '500'; + +const { app } = await import('../../server/app.mjs'); +const { db } = await import('../../server/db.mjs'); +const jsonRequest = (path, options = {}) => app.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, +}); + +async function upload(projectId, token, taskId) { + const form = new FormData(); + form.append('file', new Blob([`content-${taskId}`], { type: 'text/plain' }), `${taskId}.txt`); + form.set('taskId', taskId); + const response = await app.request(`/api/projects/${projectId}/attachments`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: form, + }); + assert.equal(response.status, 200); + return response.json(); +} + +test('attachment listing refreshes without N+1 queries or internal identifier leakage', async () => { + let response = await jsonRequest('/api/auth/signup', { + method: 'POST', + body: JSON.stringify({ email: 'attachments@scopeweave.test', password: 'password123', name: 'Attachments' }), + }); + assert.equal(response.status, 200); + const token = (await response.json()).token; + const auth = { authorization: `Bearer ${token}` }; + response = await jsonRequest('/api/me', { headers: auth }); + const userId = (await response.json()).user.id; + response = await jsonRequest('/api/projects', { + method: 'POST', headers: auth, body: JSON.stringify({ name: 'Attachment Status Project' }), + }); + assert.equal(response.status, 200); + const projectId = (await response.json()).id; + + const first = await upload(projectId, token, 'task-a'); + const second = await upload(projectId, token, 'task-b'); + db.prepare("UPDATE attachments SET status = 'PENDING' WHERE id IN (?, ?)").run(first.id, second.id); + db.prepare('INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)') + .run(projectId, 'task-missing', 'missing.txt', 'text/plain', 1, '', 'PENDING', userId); + + response = await jsonRequest(`/api/projects/${projectId}/attachments?taskId=task-a`, { headers: auth }); + assert.equal(response.status, 200); + let attachments = (await response.json()).attachments; + assert.equal(attachments.length, 1); + assert.equal(attachments[0].taskId, 'task-a'); + assert.equal(attachments[0].status, 'SUCCEEDED'); + assert.equal(Object.hasOwn(attachments[0], 'jobId'), false); + + response = await jsonRequest(`/api/projects/${projectId}/attachments`, { headers: auth }); + assert.equal(response.status, 200); + attachments = (await response.json()).attachments; + assert.equal(attachments.length, 3); + assert.equal(attachments.every((row) => !Object.hasOwn(row, 'jobId')), true); + assert.equal(attachments.find((row) => row.taskId === 'task-b').status, 'SUCCEEDED'); + assert.equal(attachments.find((row) => row.taskId === 'task-missing').status, 'PENDING'); + + response = await jsonRequest('/api/metrics'); + const metrics = await response.json(); + assert.equal(metrics.attachmentStatusRefreshAttempted, 2); + assert.equal(metrics.attachmentStatusRefreshChanged, 2); + assert.equal(metrics.attachmentStatusRefreshFailed, 0); + assert.equal(metrics.attachmentStatusRefreshDeferred, 1); + + response = await jsonRequest('/api/metrics?format=prometheus'); + const prometheus = await response.text(); + assert.match(prometheus, /scopeweave_attachment_status_refresh_attempted 2/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_changed 2/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_failed 0/); + assert.match(prometheus, /scopeweave_attachment_status_refresh_deferred 1/); + + const source = readFileSync('server/app.mjs', 'utf8'); + const routeStart = source.indexOf("app.get('/api/projects/:id/attachments'"); + const routeEnd = source.indexOf('// 열람:', routeStart); + const route = source.slice(routeStart, routeEnd); + assert.ok(routeStart >= 0 && routeEnd > routeStart); + assert.equal((route.match(/a\.job_id AS jobId/g) || []).length, 2); + assert.doesNotMatch(route, /SELECT job_id FROM attachments/); + assert.match(route, /rows\.map\(\(\{ jobId: _internalJobId, \.\.\.publicRow \}\) => publicRow\)/); +}); diff --git a/tests/unit/attachment-status.test.mjs b/tests/unit/attachment-status.test.mjs new file mode 100644 index 00000000..8187ee65 --- /dev/null +++ b/tests/unit/attachment-status.test.mjs @@ -0,0 +1,126 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ATTACHMENT_STATUS_MAX_CONCURRENCY, + ATTACHMENT_STATUS_MAX_TIMEOUT_MS, + normalizeAttachmentStatusConcurrency, + normalizeAttachmentStatusTimeoutMs, + refreshAttachmentStatuses, +} from '../../server/attachment_status.mjs'; + +test('attachment status configuration is bounded and fail-safe', () => { + assert.equal(normalizeAttachmentStatusConcurrency(undefined), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); + assert.equal(normalizeAttachmentStatusConcurrency('4'), 4); + assert.equal(normalizeAttachmentStatusConcurrency(0), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); + assert.equal(normalizeAttachmentStatusConcurrency(1.5), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); + assert.equal(normalizeAttachmentStatusConcurrency(999), ATTACHMENT_STATUS_MAX_CONCURRENCY); + assert.equal(normalizeAttachmentStatusTimeoutMs(undefined), ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS); + assert.equal(normalizeAttachmentStatusTimeoutMs('25'), 25); + assert.equal(normalizeAttachmentStatusTimeoutMs(-1), ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS); + assert.equal(normalizeAttachmentStatusTimeoutMs(50_000), ATTACHMENT_STATUS_MAX_TIMEOUT_MS); +}); + +test('refresh validates its dependency contract', async () => { + await assert.rejects(() => refreshAttachmentStatuses(null, {}), /rows must be an array/); + await assert.rejects(() => refreshAttachmentStatuses([], undefined), /jobStatus must be a function/); + await assert.rejects(() => refreshAttachmentStatuses([], { updateStatus() {} }), /jobStatus must be a function/); + await assert.rejects(() => refreshAttachmentStatuses([], { jobStatus() {} }), /updateStatus must be a function/); +}); + +test('empty and settled rows perform no downstream work', async () => { + const dependencies = { + jobStatus: async () => { throw new Error('must not run'); }, + updateStatus: () => { throw new Error('must not run'); }, + }; + assert.deepEqual(await refreshAttachmentStatuses([], dependencies), { + attempted: 0, changed: 0, failed: 0, deferred: 0, + }); + const metrics = {}; + assert.deepEqual( + await refreshAttachmentStatuses([null, { id: 1, status: 'SUCCEEDED', jobId: 'job-1' }], { ...dependencies, metrics }), + { attempted: 0, changed: 0, failed: 0, deferred: 0 }, + ); + assert.deepEqual(metrics, { + attachmentStatusRefreshAttempted: 0, + attachmentStatusRefreshChanged: 0, + attachmentStatusRefreshFailed: 0, + attachmentStatusRefreshDeferred: 0, + }); +}); + +test('100 pending rows respect configured concurrency and persist only changes', async () => { + const rows = Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + jobId: `job-${index + 1}`, + status: index % 3 === 0 ? 'RUNNING' : 'PENDING', + })); + let active = 0; + let peak = 0; + const updates = []; + const metrics = { + attachmentStatusRefreshAttempted: 10, + attachmentStatusRefreshChanged: 20, + attachmentStatusRefreshFailed: 30, + attachmentStatusRefreshDeferred: 40, + }; + const counts = await refreshAttachmentStatuses(rows, { + orgId: 7, + userId: 9, + concurrency: 8, + timeoutMs: 1_000, + metrics, + jobStatus: async (orgId, userId, jobId, { signal }) => { + assert.equal(orgId, 7); + assert.equal(userId, 9); + assert.equal(signal.aborted, false); + active += 1; + peak = Math.max(peak, active); + const rowNumber = Number(jobId.split('-')[1]); + await new Promise((resolve) => setTimeout(resolve, rowNumber % 3)); + active -= 1; + return rowNumber % 2 === 0 ? 'SUCCEEDED' : rows[rowNumber - 1].status; + }, + updateStatus: async (status, attachmentId) => updates.push([status, attachmentId]), + }); + assert.ok(peak <= 8, `peak concurrency ${peak} exceeded configured limit`); + assert.deepEqual(counts, { attempted: 100, changed: 50, failed: 0, deferred: 0 }); + assert.equal(updates.length, 50); + assert.deepEqual(metrics, { + attachmentStatusRefreshAttempted: 110, + attachmentStatusRefreshChanged: 70, + attachmentStatusRefreshFailed: 30, + attachmentStatusRefreshDeferred: 40, + }); +}); + +test('invalid identifiers and downstream, timeout, or write failures remain isolated', async () => { + const rows = [ + { id: 1, jobId: null, status: 'PENDING' }, + { id: 2, jobId: '', status: 'RUNNING' }, + { id: 3, jobId: ' ', status: 'PENDING' }, + { id: 4, jobId: 'throws', status: 'PENDING' }, + { id: 5, jobId: 'invalid-status', status: 'PENDING' }, + { id: 6, jobId: 'write-fails', status: 'PENDING' }, + { id: 7, jobId: 'times-out', status: 'PENDING' }, + ]; + let aborted = false; + const counts = await refreshAttachmentStatuses(rows, { + concurrency: 3, + timeoutMs: 5, + jobStatus: async (_orgId, _userId, jobId, { signal }) => { + if (jobId === 'throws') throw new Error('downstream failure'); + if (jobId === 'invalid-status') return 'UNKNOWN'; + if (jobId === 'write-fails') return 'SUCCEEDED'; + return new Promise(() => { + signal.addEventListener('abort', () => { aborted = true; }, { once: true }); + }); + }, + updateStatus: () => { throw new Error('write failure'); }, + }); + assert.equal(aborted, true); + assert.deepEqual(counts, { attempted: 4, changed: 0, failed: 4, deferred: 3 }); + assert.equal(rows[5].status, 'PENDING'); + assert.equal(rows[6].status, 'PENDING'); +}); diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs new file mode 100644 index 00000000..d4c28189 --- /dev/null +++ b/tests/unit/clearfolio-status-signal.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +test('Clearfolio jobStatus forwards the caller abort signal', async () => { + process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; + const originalFetch = globalThis.fetch; + let observedSignal; + globalThis.fetch = async (_url, options) => { + observedSignal = options.signal; + return { json: async () => ({ status: 'RUNNING' }) }; + }; + try { + const { jobStatus } = await import('../../server/clearfolio.mjs?status-signal-test=1'); + const controller = new AbortController(); + const status = await jobStatus(1, 2, 'job-1', { signal: controller.signal }); + assert.equal(status, 'RUNNING'); + assert.equal(observedSignal, controller.signal); + } finally { + globalThis.fetch = originalFetch; + delete process.env.CLEARFOLIO_URL; + } +}); From 4f6565f52f0c64dbdaa20d0ad2cbc31d1991505f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 16:13:03 +0900 Subject: [PATCH 22/45] chore: remove unrelated scorecard workflow --- .github/workflows/scorecard-analysis.yml | 38 ------------------------ 1 file changed, 38 deletions(-) delete mode 100644 .github/workflows/scorecard-analysis.yml diff --git a/.github/workflows/scorecard-analysis.yml b/.github/workflows/scorecard-analysis.yml deleted file mode 100644 index 9c482842..00000000 --- a/.github/workflows/scorecard-analysis.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Scorecard analysis - -on: - push: - branches: ["develop"] - schedule: - - cron: "30 1 * * 6" - -permissions: read-all - -jobs: - analysis: - name: Scorecard analysis - runs-on: ubuntu-latest - permissions: - security-events: write - id-token: write - contents: read - issues: read - pull-requests: read - checks: read - steps: - - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - name: Run analysis - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 - with: - results_file: results.sarif - results_format: sarif - publish_results: true - - - name: Upload to code scanning - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 - with: - sarif_file: results.sarif From dddfd6024a52d1a7b8ccc36408fc0ecf0a507949 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 16:13:37 +0900 Subject: [PATCH 23/45] chore: keep attachment change focused --- .jules/bolt.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index fddde4bb..b08b203a 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -4,7 +4,3 @@ ## 2026-07-12 - Optimize renderTaskRow DOM allocations **Learning:** Caching unattached template nodes and instantiating them via `.cloneNode(false)` reduces DOM instantiation overhead in O(N) render loops significantly. **Action:** Apply this optimization to other hot-path rendering elements such as rows, cells, and stack containers. - -## 2026-08-04 - Promise.all prevents blocking loop cascades -**Learning:** Sequential `for...of` loops awaiting external network requests inside Node.js serialize I/O, causing latency proportional to the array size. Concurrent execution via `Promise.all` removes that, but an unbounded `Promise.all(rows.map(...))` starts every external call at once and can exhaust upstream connections or rate limits. -**Action:** Use plain `Promise.all` only for small fixed batches. For external database/network calls over arbitrarily sized arrays, bound concurrency (chunked `Promise.all` or a small worker pool) and keep per-item failure handling best-effort. From bb45a518ceb8441744ce3d5a2f004f32d870439d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 17:54:47 +0900 Subject: [PATCH 24/45] fix(attachments): enforce request-wide refresh budget --- server/attachment_status.mjs | 100 +++++++++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 5 deletions(-) diff --git a/server/attachment_status.mjs b/server/attachment_status.mjs index fb9acdc9..e92767c8 100644 --- a/server/attachment_status.mjs +++ b/server/attachment_status.mjs @@ -10,9 +10,18 @@ export const ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS = 3_000; /** Hard ceiling for a downstream status lookup timeout in milliseconds. */ export const ATTACHMENT_STATUS_MAX_TIMEOUT_MS = 30_000; +/** Default wall-clock budget for one attachment-list refresh pass. */ +export const ATTACHMENT_STATUS_DEFAULT_BUDGET_MS = 5_000; + +/** Hard ceiling for one attachment-list refresh pass. */ +export const ATTACHMENT_STATUS_MAX_BUDGET_MS = 60_000; + /** Status values accepted from the Clearfolio conversion contract. */ const ATTACHMENT_STATUS_VALUES = new Set(['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED']); +/** Timeout error name used only for sanitized failure categorization. */ +const ATTACHMENT_STATUS_TIMEOUT_ERROR = 'AttachmentStatusTimeoutError'; + /** * Normalize a positive integer while applying a conservative upper bound. * @@ -55,6 +64,33 @@ export function normalizeAttachmentStatusTimeoutMs(value) { ); } +/** + * Normalize the request-wide attachment refresh budget. + * + * @param {unknown} value - Environment or caller supplied value. + * @returns {number} A positive budget no greater than 60 seconds. + */ +export function normalizeAttachmentStatusBudgetMs(value) { + return normalizeBoundedInteger( + value, + ATTACHMENT_STATUS_DEFAULT_BUDGET_MS, + ATTACHMENT_STATUS_MAX_BUDGET_MS, + ); +} + +/** + * Read a clock dependency and reject unusable values before deadline math. + * + * @param {() => number} clock - Clock returning epoch-like milliseconds. + * @returns {number} A finite millisecond value. + * @throws {TypeError} If the clock returns a non-finite value. + */ +function readClock(clock) { + const value = clock(); + if (!Number.isFinite(value)) throw new TypeError('clock must return a finite number'); + return value; +} + /** * Add one refresh result to process-level operational counters. * @@ -75,6 +111,26 @@ function addRefreshMetrics(metrics, counts) { } } +/** + * Publish a sanitized refresh-failure category without risking the request. + * + * The callback never receives a Clearfolio job identifier, URL, response body, + * or raw downstream error. A failing diagnostic sink is isolated because + * observability must not break attachment listing. + * + * @param {((event:{category:string}) => unknown)|undefined} onError - Optional diagnostic sink. + * @param {string} category - Fixed safe failure category. + * @returns {void} + */ +function reportRefreshFailure(onError, category) { + if (!onError) return; + try { + onError({ category }); + } catch { + // Diagnostics are best effort and must never fail the list response. + } +} + /** * Await one downstream lookup with an AbortSignal and a hard caller-side timeout. * @@ -91,7 +147,9 @@ async function withTimeout(lookup, controller, timeoutMs) { const timeout = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); - reject(new Error('attachment status lookup timed out')); + const error = new Error('attachment status lookup timed out'); + error.name = ATTACHMENT_STATUS_TIMEOUT_ERROR; + reject(error); }, timeoutMs); }); try { @@ -105,8 +163,11 @@ async function withTimeout(lookup, controller, timeoutMs) { * Refresh pending attachment statuses through a bounded worker pool. * * Rows are updated in place so the caller can serialize the refreshed public - * representation. Missing job identifiers and downstream or persistence - * failures preserve stale status and never fail the attachment-list response. + * representation. A shared wall-clock deadline bounds the whole refresh pass; + * workers clamp each lookup timeout to the remaining request budget and mark + * unstarted rows as deferred after the deadline. Missing job identifiers and + * downstream, validation, or persistence failures preserve stale status and + * never fail the attachment-list response. * * @param {Array} rows - Attachment rows containing `id`, `status`, and `jobId`. * @param {object} options - Downstream functions, tenant identifiers, limits, and metrics. @@ -116,18 +177,30 @@ async function withTimeout(lookup, controller, timeoutMs) { * @param {(status: string, attachmentId: unknown) => unknown|Promise} options.updateStatus - Changed-only persistence callback. * @param {unknown} [options.concurrency] - Maximum concurrent lookups. * @param {unknown} [options.timeoutMs] - Per-lookup timeout in milliseconds. + * @param {unknown} [options.budgetMs] - Request-wide refresh budget in milliseconds. * @param {object} [options.metrics] - Mutable process metrics object. + * @param {(event:{category:string}) => unknown} [options.onError] - Sanitized diagnostic callback. + * @param {() => number} [options.now] - Injectable finite millisecond clock for deterministic tests. * @returns {Promise<{attempted:number,changed:number,failed:number,deferred:number}>} Structured counters. */ export async function refreshAttachmentStatuses(rows, options) { if (!Array.isArray(rows)) throw new TypeError('rows must be an array'); if (typeof options?.jobStatus !== 'function') throw new TypeError('jobStatus must be a function'); if (typeof options?.updateStatus !== 'function') throw new TypeError('updateStatus must be a function'); + if (options.onError !== undefined && typeof options.onError !== 'function') { + throw new TypeError('onError must be a function'); + } + if (options.now !== undefined && typeof options.now !== 'function') { + throw new TypeError('now must be a function'); + } const counts = { attempted: 0, changed: 0, failed: 0, deferred: 0 }; const pending = rows.filter((row) => row?.status === 'PENDING' || row?.status === 'RUNNING'); const concurrency = normalizeAttachmentStatusConcurrency(options.concurrency); const timeoutMs = normalizeAttachmentStatusTimeoutMs(options.timeoutMs); + const budgetMs = normalizeAttachmentStatusBudgetMs(options.budgetMs); + const clock = options.now || Date.now; + const deadline = readClock(clock) + budgetMs; let cursor = 0; /** @@ -145,6 +218,12 @@ export async function refreshAttachmentStatuses(rows, options) { cursor += 1; if (index >= pending.length) return; const row = pending[index]; + const remainingBudgetMs = deadline - readClock(clock); + if (remainingBudgetMs <= 0) { + counts.deferred += 1; + continue; + } + const jobId = typeof row.jobId === 'string' ? row.jobId.trim() : ''; if (!jobId) { counts.deferred += 1; @@ -153,7 +232,12 @@ export async function refreshAttachmentStatuses(rows, options) { counts.attempted += 1; const controller = new AbortController(); + let failureCategory = 'downstream_lookup'; try { + const effectiveTimeoutMs = Math.max( + 1, + Math.min(timeoutMs, Math.ceil(remainingBudgetMs)), + ); const nextStatus = await withTimeout( () => options.jobStatus( options.orgId, @@ -162,18 +246,24 @@ export async function refreshAttachmentStatuses(rows, options) { { signal: controller.signal }, ), controller, - timeoutMs, + effectiveTimeoutMs, ); + failureCategory = 'invalid_status'; if (!ATTACHMENT_STATUS_VALUES.has(nextStatus)) { throw new Error('invalid downstream status'); } if (nextStatus !== row.status) { + failureCategory = 'status_persistence'; await options.updateStatus(nextStatus, row.id); row.status = nextStatus; counts.changed += 1; } - } catch { + } catch (error) { counts.failed += 1; + const category = error?.name === ATTACHMENT_STATUS_TIMEOUT_ERROR + ? 'timeout' + : failureCategory; + reportRefreshFailure(options.onError, category); } } } From f46c36a72fff7f6f4072bde57dd8d7cb231faf22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 17:56:32 +0900 Subject: [PATCH 25/45] fix(clearfolio): preserve status on HTTP failures --- server/clearfolio.mjs | 88 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 12 deletions(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index 29ccc048..e5db9f08 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -7,16 +7,34 @@ const CF_URL = (process.env.CLEARFOLIO_URL || '').replace(/\/$/, ''); const CF_SECRET = process.env.CLEARFOLIO_HMAC_SECRET || ''; const PERMISSIONS = 'job:create,job:read,viewer:read,artifact-link:create'; +/** Whether the process uses the in-memory Clearfolio development adapter. */ export const clearfolioMock = !CF_URL; -// Clearfolio TenantAccessService.signClaims와 동일한 규격: -// payload = tenantId \n subjectId \n permissions \n issuedAt(epoch초), -// HMAC-SHA256 → base64url(무패딩). +/** + * Sign tenant claims using the Clearfolio HMAC interoperability contract. + * + * The payload is the newline-delimited tenant ID, subject ID, permissions, and + * issued-at epoch value. The signature is unpadded base64url HMAC-SHA256. + * + * @param {string} tenantId - Clearfolio tenant identifier. + * @param {string} subjectId - Clearfolio subject identifier. + * @param {string} permissions - Comma-separated permission contract. + * @param {string|number} issuedAt - Epoch-second issue time. + * @param {string} secret - Shared HMAC secret. + * @returns {string} Unpadded base64url signature. + */ export function signClaims(tenantId, subjectId, permissions, issuedAt, secret) { const payload = [tenantId, subjectId, permissions, issuedAt].join('\n'); return createHmac('sha256', secret).update(payload).digest('base64url'); } +/** + * Build tenant-scoped Clearfolio request headers without exposing credentials. + * + * @param {string|number} orgId - ScopeWeave organization identifier. + * @param {string|number} userId - Requesting ScopeWeave user identifier. + * @returns {Record} Tenant, subject, permission, and optional HMAC headers. + */ function tenantHeaders(orgId, userId) { const tenantId = `sw-org-${orgId}`; const subjectId = `sw-user-${userId}`; @@ -28,7 +46,13 @@ function tenantHeaders(orgId, userId) { if (CF_SECRET) { const issuedAt = String(Math.floor(Date.now() / 1000)); headers['X-Clearfolio-Claims-Issued-At'] = issuedAt; - headers['X-Clearfolio-Claims-Signature'] = signClaims(tenantId, subjectId, PERMISSIONS, issuedAt, CF_SECRET); + headers['X-Clearfolio-Claims-Signature'] = signClaims( + tenantId, + subjectId, + PERMISSIONS, + issuedAt, + CF_SECRET, + ); } return headers; } @@ -36,8 +60,24 @@ function tenantHeaders(orgId, userId) { // ---- mock store (dev/test 전용; 재시작 시 소실) ---- const mockDocs = new Map(); // jobId -> { name, mime, bytes } let mockSeq = 0; + +/** + * Read one in-memory mock artifact. + * + * @param {string} jobId - Mock conversion job identifier. + * @returns {{name:string,mime:string,bytes:Buffer}|null} Stored artifact or null. + */ export const mockArtifact = (jobId) => mockDocs.get(jobId) || null; +/** + * Submit a document conversion job through Clearfolio or the local mock. + * + * @param {string|number} orgId - ScopeWeave organization identifier. + * @param {string|number} userId - Requesting ScopeWeave user identifier. + * @param {{name:string,mime:string,bytes:Buffer|Uint8Array}} document - Conversion payload. + * @returns {Promise<{jobId:string,status:string}>} Downstream job identity and initial status. + * @throws {Error} If Clearfolio rejects the request or omits a job identifier. + */ export async function submitJob(orgId, userId, { name, mime, bytes }) { if (clearfolioMock) { const jobId = `mockcf-${++mockSeq}`; @@ -52,19 +92,25 @@ export async function submitJob(orgId, userId, { name, mime, bytes }) { body: form, }); const data = await res.json().catch(() => ({})); - if (!res.ok || !data.jobId) throw new Error(data.message || `clearfolio submit failed (${res.status})`); + if (!res.ok || !data.jobId) { + throw new Error(data.message || `clearfolio submit failed (${res.status})`); + } return { jobId: data.jobId, status: data.status || 'PENDING' }; } - /** * Read a Clearfolio conversion status with optional caller cancellation. * + * Non-success HTTP responses throw instead of being converted to `FAILED`. + * This allows the bounded refresh engine to preserve the previously persisted + * status when Clearfolio itself is temporarily unavailable or rejects a request. + * * @param {string|number} orgId - ScopeWeave organization identifier. * @param {string|number} userId - Requesting user identifier. * @param {string} jobId - Clearfolio conversion job identifier. * @param {{signal?:AbortSignal}} [options] - Optional request cancellation signal. * @returns {Promise} Downstream conversion status. + * @throws {Error} If Clearfolio returns a non-success HTTP status. */ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; @@ -73,10 +119,22 @@ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { signal, }); const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(`clearfolio status failed (${res.status})`); return data.status || 'FAILED'; } -// SUCCEEDED 잡의 서명 아티팩트 URL 발급 → 뷰어/직접 열람용 절대 URL 반환. +/** + * Issue a viewable artifact URL for a completed Clearfolio job. + * + * The hosted path prefers Clearfolio's external PDF.js viewer when an + * `artifactToken` is available and falls back to the signed artifact URL. + * + * @param {string|number} orgId - ScopeWeave organization identifier. + * @param {string|number} userId - Requesting ScopeWeave user identifier. + * @param {string} jobId - Completed conversion job identifier. + * @returns {Promise} Relative mock path or absolute hosted artifact URL. + * @throws {Error} If Clearfolio cannot issue an artifact link. + */ export async function artifactUrl(orgId, userId, jobId) { if (clearfolioMock) return `/api/mock-clearfolio/${encodeURIComponent(jobId)}`; const res = await fetch(`${CF_URL}/api/v1/viewer/${encodeURIComponent(jobId)}/artifact-links`, { @@ -85,13 +143,19 @@ export async function artifactUrl(orgId, userId, jobId) { }); const data = await res.json().catch(() => ({})); const link = data.artifactUrl || data.url || data.signedUrl; - if (!res.ok || !link) throw new Error(data.message || `clearfolio artifact-link failed (${res.status})`); + if (!res.ok || !link) { + throw new Error(data.message || `clearfolio artifact-link failed (${res.status})`); + } // PDF.js 뷰어 페이지 우선(clearfolio external artifactToken 모드): 토큰을 // 추출해 /viewer/{docId}?artifactToken=… 으로 보낸다. 실패 시 원시 아티팩트. try { - const u = new URL(link, CF_URL); - const tok = u.searchParams.get('artifactToken'); - if (tok) return `${CF_URL}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(tok)}`; - } catch { /* fall through to raw link */ } + const url = new URL(link, CF_URL); + const token = url.searchParams.get('artifactToken'); + if (token) { + return `${CF_URL}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(token)}`; + } + } catch { + // Fall through to the signed raw artifact link. + } return link.startsWith('http') ? link : `${CF_URL}${link}`; } From d0a1b44e5f0ac61afc992741092eaa7bd6031d34 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 17:59:13 +0900 Subject: [PATCH 26/45] test(attachments): prove deadline and failure categories --- tests/unit/attachment-status.test.mjs | 185 +++++++++++++++++++++++--- 1 file changed, 163 insertions(+), 22 deletions(-) diff --git a/tests/unit/attachment-status.test.mjs b/tests/unit/attachment-status.test.mjs index 8187ee65..7c299cda 100644 --- a/tests/unit/attachment-status.test.mjs +++ b/tests/unit/attachment-status.test.mjs @@ -1,32 +1,99 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + ATTACHMENT_STATUS_DEFAULT_BUDGET_MS, ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ATTACHMENT_STATUS_MAX_BUDGET_MS, ATTACHMENT_STATUS_MAX_CONCURRENCY, ATTACHMENT_STATUS_MAX_TIMEOUT_MS, + normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses, } from '../../server/attachment_status.mjs'; test('attachment status configuration is bounded and fail-safe', () => { - assert.equal(normalizeAttachmentStatusConcurrency(undefined), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); + assert.equal( + normalizeAttachmentStatusConcurrency(undefined), + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ); assert.equal(normalizeAttachmentStatusConcurrency('4'), 4); - assert.equal(normalizeAttachmentStatusConcurrency(0), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); - assert.equal(normalizeAttachmentStatusConcurrency(1.5), ATTACHMENT_STATUS_DEFAULT_CONCURRENCY); - assert.equal(normalizeAttachmentStatusConcurrency(999), ATTACHMENT_STATUS_MAX_CONCURRENCY); - assert.equal(normalizeAttachmentStatusTimeoutMs(undefined), ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS); + assert.equal( + normalizeAttachmentStatusConcurrency(0), + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ); + assert.equal( + normalizeAttachmentStatusConcurrency(1.5), + ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, + ); + assert.equal( + normalizeAttachmentStatusConcurrency(999), + ATTACHMENT_STATUS_MAX_CONCURRENCY, + ); + + assert.equal( + normalizeAttachmentStatusTimeoutMs(undefined), + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ); assert.equal(normalizeAttachmentStatusTimeoutMs('25'), 25); - assert.equal(normalizeAttachmentStatusTimeoutMs(-1), ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS); - assert.equal(normalizeAttachmentStatusTimeoutMs(50_000), ATTACHMENT_STATUS_MAX_TIMEOUT_MS); + assert.equal( + normalizeAttachmentStatusTimeoutMs(-1), + ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, + ); + assert.equal( + normalizeAttachmentStatusTimeoutMs(50_000), + ATTACHMENT_STATUS_MAX_TIMEOUT_MS, + ); + + assert.equal( + normalizeAttachmentStatusBudgetMs(undefined), + ATTACHMENT_STATUS_DEFAULT_BUDGET_MS, + ); + assert.equal(normalizeAttachmentStatusBudgetMs('2500'), 2_500); + assert.equal( + normalizeAttachmentStatusBudgetMs(0), + ATTACHMENT_STATUS_DEFAULT_BUDGET_MS, + ); + assert.equal( + normalizeAttachmentStatusBudgetMs(100_000), + ATTACHMENT_STATUS_MAX_BUDGET_MS, + ); }); -test('refresh validates its dependency contract', async () => { - await assert.rejects(() => refreshAttachmentStatuses(null, {}), /rows must be an array/); - await assert.rejects(() => refreshAttachmentStatuses([], undefined), /jobStatus must be a function/); - await assert.rejects(() => refreshAttachmentStatuses([], { updateStatus() {} }), /jobStatus must be a function/); - await assert.rejects(() => refreshAttachmentStatuses([], { jobStatus() {} }), /updateStatus must be a function/); +test('refresh validates its dependency, diagnostic, and clock contracts', async () => { + const dependencies = { + jobStatus: async () => 'PENDING', + updateStatus() {}, + }; + await assert.rejects( + () => refreshAttachmentStatuses(null, {}), + /rows must be an array/, + ); + await assert.rejects( + () => refreshAttachmentStatuses([], undefined), + /jobStatus must be a function/, + ); + await assert.rejects( + () => refreshAttachmentStatuses([], { updateStatus() {} }), + /jobStatus must be a function/, + ); + await assert.rejects( + () => refreshAttachmentStatuses([], { jobStatus() {} }), + /updateStatus must be a function/, + ); + await assert.rejects( + () => refreshAttachmentStatuses([], { ...dependencies, onError: 'log' }), + /onError must be a function/, + ); + await assert.rejects( + () => refreshAttachmentStatuses([], { ...dependencies, now: 1 }), + /now must be a function/, + ); + await assert.rejects( + () => refreshAttachmentStatuses([], { ...dependencies, now: () => Number.NaN }), + /clock must return a finite number/, + ); }); test('empty and settled rows perform no downstream work', async () => { @@ -35,11 +102,18 @@ test('empty and settled rows perform no downstream work', async () => { updateStatus: () => { throw new Error('must not run'); }, }; assert.deepEqual(await refreshAttachmentStatuses([], dependencies), { - attempted: 0, changed: 0, failed: 0, deferred: 0, + attempted: 0, + changed: 0, + failed: 0, + deferred: 0, }); + const metrics = {}; assert.deepEqual( - await refreshAttachmentStatuses([null, { id: 1, status: 'SUCCEEDED', jobId: 'job-1' }], { ...dependencies, metrics }), + await refreshAttachmentStatuses( + [null, { id: 1, status: 'SUCCEEDED', jobId: 'job-1' }], + { ...dependencies, metrics }, + ), { attempted: 0, changed: 0, failed: 0, deferred: 0 }, ); assert.deepEqual(metrics, { @@ -50,7 +124,7 @@ test('empty and settled rows perform no downstream work', async () => { }); }); -test('100 pending rows respect configured concurrency and persist only changes', async () => { +test('100 pending rows reach but never exceed configured concurrency', async () => { const rows = Array.from({ length: 100 }, (_, index) => ({ id: index + 1, jobId: `job-${index + 1}`, @@ -58,6 +132,11 @@ test('100 pending rows respect configured concurrency and persist only changes', })); let active = 0; let peak = 0; + let started = 0; + let releaseInitialWorkers; + const initialWorkerGate = new Promise((resolve) => { + releaseInitialWorkers = resolve; + }); const updates = []; const metrics = { attachmentStatusRefreshAttempted: 10, @@ -65,26 +144,33 @@ test('100 pending rows respect configured concurrency and persist only changes', attachmentStatusRefreshFailed: 30, attachmentStatusRefreshDeferred: 40, }; + const counts = await refreshAttachmentStatuses(rows, { orgId: 7, userId: 9, concurrency: 8, timeoutMs: 1_000, + budgetMs: 10_000, metrics, jobStatus: async (orgId, userId, jobId, { signal }) => { assert.equal(orgId, 7); assert.equal(userId, 9); assert.equal(signal.aborted, false); active += 1; + started += 1; peak = Math.max(peak, active); - const rowNumber = Number(jobId.split('-')[1]); - await new Promise((resolve) => setTimeout(resolve, rowNumber % 3)); + if (started === 8) releaseInitialWorkers(); + await initialWorkerGate; active -= 1; + const rowNumber = Number(jobId.split('-')[1]); return rowNumber % 2 === 0 ? 'SUCCEEDED' : rows[rowNumber - 1].status; }, - updateStatus: async (status, attachmentId) => updates.push([status, attachmentId]), + updateStatus: async (status, attachmentId) => { + updates.push([status, attachmentId]); + }, }); - assert.ok(peak <= 8, `peak concurrency ${peak} exceeded configured limit`); + + assert.equal(peak, 8, `peak concurrency ${peak} did not match configured limit`); assert.deepEqual(counts, { attempted: 100, changed: 50, failed: 0, deferred: 0 }); assert.equal(updates.length, 50); assert.deepEqual(metrics, { @@ -95,7 +181,27 @@ test('100 pending rows respect configured concurrency and persist only changes', }); }); -test('invalid identifiers and downstream, timeout, or write failures remain isolated', async () => { +test('request-wide deadline defers work that has not started', async () => { + const rows = [ + { id: 1, jobId: 'job-1', status: 'PENDING' }, + { id: 2, jobId: 'job-2', status: 'PENDING' }, + { id: 3, jobId: 'job-3', status: 'PENDING' }, + ]; + const clockValues = [0, 0, 20, 30]; + const counts = await refreshAttachmentStatuses(rows, { + concurrency: 1, + timeoutMs: 1_000, + budgetMs: 15, + now: () => clockValues.shift() ?? 30, + jobStatus: async () => 'PENDING', + updateStatus: () => { throw new Error('unchanged status must not be written'); }, + }); + + assert.deepEqual(counts, { attempted: 1, changed: 0, failed: 0, deferred: 2 }); + assert.deepEqual(rows.map((row) => row.status), ['PENDING', 'PENDING', 'PENDING']); +}); + +test('invalid identifiers and categorized failures preserve stale state', async () => { const rows = [ { id: 1, jobId: null, status: 'PENDING' }, { id: 2, jobId: '', status: 'RUNNING' }, @@ -106,21 +212,56 @@ test('invalid identifiers and downstream, timeout, or write failures remain isol { id: 7, jobId: 'times-out', status: 'PENDING' }, ]; let aborted = false; + const categories = []; const counts = await refreshAttachmentStatuses(rows, { concurrency: 3, timeoutMs: 5, + budgetMs: 1_000, + onError: ({ category }) => categories.push(category), jobStatus: async (_orgId, _userId, jobId, { signal }) => { - if (jobId === 'throws') throw new Error('downstream failure'); + if (jobId === 'throws') throw new Error('downstream failure with sensitive detail'); if (jobId === 'invalid-status') return 'UNKNOWN'; if (jobId === 'write-fails') return 'SUCCEEDED'; return new Promise(() => { signal.addEventListener('abort', () => { aborted = true; }, { once: true }); }); }, - updateStatus: () => { throw new Error('write failure'); }, + updateStatus: (_status, attachmentId) => { + if (attachmentId === 6) throw new Error('write failure'); + }, }); + assert.equal(aborted, true); assert.deepEqual(counts, { attempted: 4, changed: 0, failed: 4, deferred: 3 }); + assert.deepEqual( + categories.sort(), + ['downstream_lookup', 'invalid_status', 'status_persistence', 'timeout'].sort(), + ); + assert.equal(categories.some((category) => category.includes('sensitive')), false); assert.equal(rows[5].status, 'PENDING'); assert.equal(rows[6].status, 'PENDING'); }); + +test('diagnostic sink failures and omitted diagnostics stay isolated', async () => { + const row = [{ id: 1, jobId: 'job-1', status: 'PENDING' }]; + const dependencies = { + timeoutMs: 100, + budgetMs: 1_000, + jobStatus: async () => { throw new Error('downstream failure'); }, + updateStatus() {}, + }; + + assert.deepEqual(await refreshAttachmentStatuses(row, dependencies), { + attempted: 1, + changed: 0, + failed: 1, + deferred: 0, + }); + assert.deepEqual( + await refreshAttachmentStatuses(row, { + ...dependencies, + onError: () => { throw new Error('logger unavailable'); }, + }), + { attempted: 1, changed: 0, failed: 1, deferred: 0 }, + ); +}); From 4f1dfe1382f19486e30903a5f6fdcb17effed466 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 17:59:59 +0900 Subject: [PATCH 27/45] test(clearfolio): enforce status endpoint HTTP contract --- tests/unit/clearfolio-status-signal.test.mjs | 35 ++++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs index d4c28189..ef5f94ac 100644 --- a/tests/unit/clearfolio-status-signal.test.mjs +++ b/tests/unit/clearfolio-status-signal.test.mjs @@ -1,20 +1,49 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -test('Clearfolio jobStatus forwards the caller abort signal', async () => { +test('Clearfolio jobStatus enforces endpoint, signal, and HTTP status contracts', async () => { process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; const originalFetch = globalThis.fetch; + let observedUrl; let observedSignal; - globalThis.fetch = async (_url, options) => { + let downstreamResponse = { + ok: true, + status: 200, + json: async () => ({ status: 'RUNNING' }), + }; + globalThis.fetch = async (url, options) => { + observedUrl = String(url); observedSignal = options.signal; - return { json: async () => ({ status: 'RUNNING' }) }; + return downstreamResponse; }; + try { const { jobStatus } = await import('../../server/clearfolio.mjs?status-signal-test=1'); const controller = new AbortController(); const status = await jobStatus(1, 2, 'job-1', { signal: controller.signal }); assert.equal(status, 'RUNNING'); + assert.equal( + observedUrl, + 'https://clearfolio.example/api/v1/convert/jobs/job-1', + ); assert.equal(observedSignal, controller.signal); + + downstreamResponse = { + ok: false, + status: 503, + json: async () => ({ message: 'sensitive downstream text' }), + }; + await assert.rejects( + () => jobStatus(1, 2, 'job-1'), + /clearfolio status failed \(503\)/, + ); + + downstreamResponse = { + ok: true, + status: 200, + json: async () => ({}), + }; + assert.equal(await jobStatus(1, 2, 'job-1'), 'FAILED'); } finally { globalThis.fetch = originalFetch; delete process.env.CLEARFOLIO_URL; From 609c78c0157a59550c5667b64601a3e0d7d6be5c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 18:01:01 +0900 Subject: [PATCH 28/45] test(attachments): use behavior-only list assertions --- tests/api/attachment-status.test.mjs | 72 +++++++++++++++++++--------- 1 file changed, 50 insertions(+), 22 deletions(-) diff --git a/tests/api/attachment-status.test.mjs b/tests/api/attachment-status.test.mjs index c81eadf1..1b840fcb 100644 --- a/tests/api/attachment-status.test.mjs +++ b/tests/api/attachment-status.test.mjs @@ -1,11 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY = '2'; process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS = '500'; +process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS = '1000'; +process.env.CLEARFOLIO_URL = ''; const { app } = await import('../../server/app.mjs'); const { db } = await import('../../server/db.mjs'); @@ -16,7 +17,11 @@ const jsonRequest = (path, options = {}) => app.request(path, { async function upload(projectId, token, taskId) { const form = new FormData(); - form.append('file', new Blob([`content-${taskId}`], { type: 'text/plain' }), `${taskId}.txt`); + form.append( + 'file', + new Blob([`content-${taskId}`], { type: 'text/plain' }), + `${taskId}.txt`, + ); form.set('taskId', taskId); const response = await app.request(`/api/projects/${projectId}/attachments`, { method: 'POST', @@ -27,29 +32,50 @@ async function upload(projectId, token, taskId) { return response.json(); } -test('attachment listing refreshes without N+1 queries or internal identifier leakage', async () => { +test('attachment listing refreshes without internal identifier leakage', async () => { let response = await jsonRequest('/api/auth/signup', { method: 'POST', - body: JSON.stringify({ email: 'attachments@scopeweave.test', password: 'password123', name: 'Attachments' }), + body: JSON.stringify({ + email: 'attachments@scopeweave.test', + password: 'password123', + name: 'Attachments', + }), }); assert.equal(response.status, 200); const token = (await response.json()).token; const auth = { authorization: `Bearer ${token}` }; + response = await jsonRequest('/api/me', { headers: auth }); const userId = (await response.json()).user.id; response = await jsonRequest('/api/projects', { - method: 'POST', headers: auth, body: JSON.stringify({ name: 'Attachment Status Project' }), + method: 'POST', + headers: auth, + body: JSON.stringify({ name: 'Attachment Status Project' }), }); assert.equal(response.status, 200); const projectId = (await response.json()).id; const first = await upload(projectId, token, 'task-a'); const second = await upload(projectId, token, 'task-b'); - db.prepare("UPDATE attachments SET status = 'PENDING' WHERE id IN (?, ?)").run(first.id, second.id); - db.prepare('INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)') - .run(projectId, 'task-missing', 'missing.txt', 'text/plain', 1, '', 'PENDING', userId); + db.prepare("UPDATE attachments SET status = 'PENDING' WHERE id IN (?, ?)") + .run(first.id, second.id); + db.prepare( + 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)', + ).run( + projectId, + 'task-missing', + 'missing.txt', + 'text/plain', + 1, + '', + 'PENDING', + userId, + ); - response = await jsonRequest(`/api/projects/${projectId}/attachments?taskId=task-a`, { headers: auth }); + response = await jsonRequest( + `/api/projects/${projectId}/attachments?taskId=task-a`, + { headers: auth }, + ); assert.equal(response.status, 200); let attachments = (await response.json()).attachments; assert.equal(attachments.length, 1); @@ -57,13 +83,24 @@ test('attachment listing refreshes without N+1 queries or internal identifier le assert.equal(attachments[0].status, 'SUCCEEDED'); assert.equal(Object.hasOwn(attachments[0], 'jobId'), false); - response = await jsonRequest(`/api/projects/${projectId}/attachments`, { headers: auth }); + response = await jsonRequest(`/api/projects/${projectId}/attachments`, { + headers: auth, + }); assert.equal(response.status, 200); attachments = (await response.json()).attachments; assert.equal(attachments.length, 3); - assert.equal(attachments.every((row) => !Object.hasOwn(row, 'jobId')), true); - assert.equal(attachments.find((row) => row.taskId === 'task-b').status, 'SUCCEEDED'); - assert.equal(attachments.find((row) => row.taskId === 'task-missing').status, 'PENDING'); + assert.equal( + attachments.every((row) => !Object.hasOwn(row, 'jobId')), + true, + ); + assert.equal( + attachments.find((row) => row.taskId === 'task-b').status, + 'SUCCEEDED', + ); + assert.equal( + attachments.find((row) => row.taskId === 'task-missing').status, + 'PENDING', + ); response = await jsonRequest('/api/metrics'); const metrics = await response.json(); @@ -78,13 +115,4 @@ test('attachment listing refreshes without N+1 queries or internal identifier le assert.match(prometheus, /scopeweave_attachment_status_refresh_changed 2/); assert.match(prometheus, /scopeweave_attachment_status_refresh_failed 0/); assert.match(prometheus, /scopeweave_attachment_status_refresh_deferred 1/); - - const source = readFileSync('server/app.mjs', 'utf8'); - const routeStart = source.indexOf("app.get('/api/projects/:id/attachments'"); - const routeEnd = source.indexOf('// 열람:', routeStart); - const route = source.slice(routeStart, routeEnd); - assert.ok(routeStart >= 0 && routeEnd > routeStart); - assert.equal((route.match(/a\.job_id AS jobId/g) || []).length, 2); - assert.doesNotMatch(route, /SELECT job_id FROM attachments/); - assert.match(route, /rows\.map\(\(\{ jobId: _internalJobId, \.\.\.publicRow \}\) => publicRow\)/); }); From 73603cd4cc4f0247d10002764bb5de4b54d261f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 18:01:55 +0900 Subject: [PATCH 29/45] test(coverage): include Clearfolio status paths --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7c25940d..79816ee2 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,11 @@ }, "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", - "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --reporter=json --reporter=json-summary npm run test:coverage", + "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs", "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs", - "test:coverage": "node tests/unit/attachment-status.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:coverage": "node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", From e88354889b9476298938e4a8580c5352ec2a10d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 19:45:11 +0900 Subject: [PATCH 30/45] ci: repair exact-head coverage evidence for PR 419 --- .github/workflows/repair-pr-419-coverage.yml | 237 +++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 .github/workflows/repair-pr-419-coverage.yml diff --git a/.github/workflows/repair-pr-419-coverage.yml b/.github/workflows/repair-pr-419-coverage.yml new file mode 100644 index 00000000..3af2addf --- /dev/null +++ b/.github/workflows/repair-pr-419-coverage.yml @@ -0,0 +1,237 @@ +name: Repair PR 419 coverage evidence + +on: + push: + branches: + - dependabot/npm_and_yarn/npm_and_yarn-a27be0ffa6 + +permissions: read-all + +concurrency: + group: repair-pr-419-coverage + cancel-in-progress: true + +jobs: + repair: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + contents: write + steps: + - name: Checkout PR branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: dependabot/npm_and_yarn/npm_and_yarn-a27be0ffa6 + fetch-depth: 0 + persist-credentials: true + + - name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 22.13.0 + cache: npm + + - name: Apply review and coverage fixes + shell: bash + run: | + set -euo pipefail + python3 <<'PY' + from pathlib import Path + import json + + package_path = Path('package.json') + package = json.loads(package_path.read_text(encoding='utf-8')) + scripts = package['scripts'] + coverage_includes = ( + '--all --include=app.js --include=cloud-sync.js ' + '--include=scripts/ci/static_coverage_evidence.mjs ' + '--include=server/attachment_status.mjs --include=server/app.mjs ' + '--include=server/auth.mjs --include=server/clearfolio.mjs ' + '--reporter=json --reporter=json-summary' + ) + coverage_cases = ( + 'node tests/unit/coverage-script-contract.test.mjs && ' + 'node tests/unit/attachment-status.test.mjs && ' + 'node tests/unit/clearfolio-status-signal.test.mjs && ' + 'node tests/unit/msproject.test.mjs && ' + 'node tests/unit/auth-password.test.mjs && ' + 'node tests/unit/editor-unsaved.test.mjs && ' + 'node tests/unit/static-coverage-evidence.test.mjs && ' + 'npm run test:api' + ) + scripts['coverage'] = 'npm run test:coverage' + scripts['test:coverage'] = f'c8 {coverage_includes} npm run test:coverage:cases' + scripts['test:coverage:cases'] = coverage_cases + contract_test = 'node tests/unit/coverage-script-contract.test.mjs' + if contract_test not in scripts['test:unit']: + scripts['test:unit'] += f' && {contract_test}' + package_path.write_text( + json.dumps(package, ensure_ascii=False, indent=2) + '\n', + encoding='utf-8', + ) + + app_path = Path('server/app.mjs') + app = app_path.read_text(encoding='utf-8') + old_import = ( + "import { normalizeAttachmentStatusConcurrency, " + "normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } " + "from './attachment_status.mjs';" + ) + new_import = ( + "import { normalizeAttachmentStatusBudgetMs, " + "normalizeAttachmentStatusConcurrency, " + "normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } " + "from './attachment_status.mjs';" + ) + if old_import in app: + app = app.replace(old_import, new_import, 1) + elif new_import not in app: + raise SystemExit('attachment status import marker not found') + + old_constants = """const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, + ); + const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, + ); + const updateAttachmentStatusStatement = db.prepare( + 'UPDATE attachments SET status = ? WHERE id = ?', + );""" + new_constants = """const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, + ); + const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, + ); + const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, + ); + const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, + a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; + const ATTACHMENT_LIST_FROM = + 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; + const listAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? ORDER BY a.id DESC`, + ); + const listTaskAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, + ); + const updateAttachmentStatusStatement = db.prepare( + 'UPDATE attachments SET status = ? WHERE id = ?', + );""" + if old_constants in app: + app = app.replace(old_constants, new_constants, 1) + elif 'const ATTACH_STATUS_BUDGET_MS' not in app: + raise SystemExit('attachment status constant marker not found') + + route_start = app.index("app.get('/api/projects/:id/attachments', requireAuth, async (c) => {") + body_start = app.index("const taskId = c.req.query('taskId');", route_start) + body_end_marker = 'return c.json({ attachments });' + body_end = app.index(body_end_marker, body_start) + len(body_end_marker) + new_body = """ const taskId = c.req.query('taskId'); + const rows = taskId + ? listTaskAttachmentsStatement.all(p.id, taskId) + : listAttachmentsStatement.all(p.id); + await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => + updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + budgetMs: ATTACH_STATUS_BUDGET_MS, + metrics, + }); + const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); + return c.json({ attachments });""" + app = app[:body_start] + new_body + app[body_end:] + app_path.write_text(app, encoding='utf-8') + PY + + cat > tests/unit/coverage-script-contract.test.mjs <<'EOF' + import assert from 'node:assert/strict'; + import { readFileSync } from 'node:fs'; + + const packageJson = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), + ); + const scripts = packageJson.scripts; + + assert.equal( + scripts.coverage, + 'npm run test:coverage', + 'the public coverage command delegates to the canonical coverage-producing script', + ); + assert.match( + scripts['test:coverage'], + /\bc8\b.*--reporter=json.*npm run test:coverage:cases/, + 'test:coverage must create Istanbul JSON before executing coverage cases', + ); + assert.match( + scripts['test:coverage'], + /--include=server\/attachment_status\.mjs/, + 'the new bounded refresh module must be instrumented', + ); + assert.match( + scripts['test:coverage'], + /--include=server\/clearfolio\.mjs/, + 'the abortable Clearfolio adapter must be instrumented', + ); + assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/clearfolio-status-signal\.test\.mjs/, + 'the Clearfolio signal and HTTP failure regression must run under c8', + ); + assert.doesNotMatch( + scripts['test:coverage:cases'], + /npm run (?:coverage|test:coverage)(?:\s|$)/, + 'coverage cases must not recursively invoke the coverage wrapper', + ); + + console.log('✓ coverage script contract tests passed'); + EOF + + - name: Install dependencies + run: npm ci + + - name: Run focused and full verification + shell: bash + run: | + set -euo pipefail + npm run test:unit + npm run test:api + npm run coverage + test -s coverage/coverage-final.json + node scripts/ci/static_coverage_evidence.mjs docstrings + npm run test:e2e:cloud + node - <<'NODE' + const fs = require('node:fs'); + const path = require('node:path'); + const report = JSON.parse(fs.readFileSync('coverage/coverage-final.json', 'utf8')); + const target = Object.entries(report).find(([name]) => + name.replaceAll('\\\\', '/').endsWith('/server/attachment_status.mjs')); + if (!target) throw new Error('attachment_status.mjs is missing from coverage-final.json'); + const coverage = target[1]; + const misses = [ + ...Object.values(coverage.s), + ...Object.values(coverage.f), + ...Object.values(coverage.b).flat(), + ].filter((hits) => hits === 0); + if (misses.length) throw new Error(`attachment_status.mjs has ${misses.length} uncovered entries`); + NODE + git diff --check + + - name: Commit verified fixes and remove bootstrap + shell: bash + run: | + set -euo pipefail + rm .github/workflows/repair-pr-419-coverage.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add package.json server/app.mjs tests/unit/coverage-script-contract.test.mjs .github/workflows/repair-pr-419-coverage.yml + git commit -m 'fix(ci): make coverage evidence deterministic' + git push origin HEAD:dependabot/npm_and_yarn/npm_and_yarn-a27be0ffa6 From e34b138e13bc0ef8bdf69411c660bae540ca3b96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 19:47:20 +0900 Subject: [PATCH 31/45] ci: stage PR 419 review repair script --- scripts/ci/repair_pr_419.py | 152 ++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 scripts/ci/repair_pr_419.py diff --git a/scripts/ci/repair_pr_419.py b/scripts/ci/repair_pr_419.py new file mode 100644 index 00000000..6810a13c --- /dev/null +++ b/scripts/ci/repair_pr_419.py @@ -0,0 +1,152 @@ +"""One-shot, fail-closed repair for ScopeWeave PR 419. + +The script converts the repository's coverage entry point into a real Istanbul +producer, consolidates attachment-list prepared statements, and wires the +request-wide refresh budget into the Hono route. It is deleted by the repair +workflow after the exact tree passes the complete validation set. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + """Replace one exact source fragment or fail before modifying the tree.""" + + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one replacement target, found {count}") + return text.replace(old, new, 1) + + +package_path = Path("package.json") +package = json.loads(package_path.read_text(encoding="utf-8")) +scripts = package["scripts"] +coverage_includes = ( + "--all --include=app.js --include=cloud-sync.js " + "--include=scripts/ci/static_coverage_evidence.mjs " + "--include=server/attachment_status.mjs --include=server/app.mjs " + "--include=server/auth.mjs --include=server/clearfolio.mjs " + "--reporter=json --reporter=json-summary" +) +coverage_cases = ( + "node tests/unit/coverage-script-contract.test.mjs && " + "node tests/unit/attachment-status.test.mjs && " + "node tests/unit/clearfolio-status-signal.test.mjs && " + "node tests/unit/msproject.test.mjs && " + "node tests/unit/auth-password.test.mjs && " + "node tests/unit/editor-unsaved.test.mjs && " + "node tests/unit/static-coverage-evidence.test.mjs && " + "npm run test:api" +) +scripts["coverage"] = "npm run test:coverage" +scripts["test:coverage"] = f"c8 {coverage_includes} npm run test:coverage:cases" +scripts["test:coverage:cases"] = coverage_cases +contract_test = "node tests/unit/coverage-script-contract.test.mjs" +if contract_test not in scripts["test:unit"]: + scripts["test:unit"] += f" && {contract_test}" +package_path.write_text( + json.dumps(package, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", +) + +app_path = Path("server/app.mjs") +app = app_path.read_text(encoding="utf-8") +app = replace_once( + app, + "import { normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs';", + "import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs';", + "attachment-status import", +) +app = replace_once( + app, + """const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, +); +const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, +); +const updateAttachmentStatusStatement = db.prepare( + 'UPDATE attachments SET status = ? WHERE id = ?', +);""", + """const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, +); +const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, +); +const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, +); +const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, + a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; +const ATTACHMENT_LIST_FROM = + 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; +const listAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? ORDER BY a.id DESC`, +); +const listTaskAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, +); +const updateAttachmentStatusStatement = db.prepare( + 'UPDATE attachments SET status = ? WHERE id = ?', +);""", + "attachment-status constants", +) +route_start = app.index("app.get('/api/projects/:id/attachments', requireAuth, async (c) => {") +body_start = app.index("const taskId = c.req.query('taskId');", route_start) +body_end_marker = "return c.json({ attachments });" +body_end = app.index(body_end_marker, body_start) + len(body_end_marker) +new_body = """ const taskId = c.req.query('taskId'); + const rows = taskId + ? listTaskAttachmentsStatement.all(p.id, taskId) + : listAttachmentsStatement.all(p.id); + await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => + updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + budgetMs: ATTACH_STATUS_BUDGET_MS, + metrics, + }); + const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); + return c.json({ attachments });""" +app = app[:body_start] + new_body + app[body_end:] +app_path.write_text(app, encoding="utf-8") + +Path("tests/unit/coverage-script-contract.test.mjs").write_text( + """import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const packageJson = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), +); +const scripts = packageJson.scripts; + +assert.equal(scripts.coverage, 'npm run test:coverage'); +assert.match( + scripts['test:coverage'], + /\\bc8\\b.*--reporter=json.*npm run test:coverage:cases/, +); +assert.match(scripts['test:coverage'], /--include=server\\/attachment_status\\.mjs/); +assert.match(scripts['test:coverage'], /--include=server\\/clearfolio\\.mjs/); +assert.match( + scripts['test:coverage:cases'], + /tests\\/unit\\/clearfolio-status-signal\\.test\\.mjs/, +); +assert.doesNotMatch( + scripts['test:coverage:cases'], + /npm run (?:coverage|test:coverage)(?:\\s|$)/, +); + +console.log('✓ coverage script contract tests passed'); +""", + encoding="utf-8", +) From 230bafae2914c80d6681ca46ebbf6052d49c7d8b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 19:47:51 +0900 Subject: [PATCH 32/45] ci: arm ready-event repair for PR 419 --- .github/workflows/repair-pr-419-ready.yml | 78 +++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/repair-pr-419-ready.yml diff --git a/.github/workflows/repair-pr-419-ready.yml b/.github/workflows/repair-pr-419-ready.yml new file mode 100644 index 00000000..fb23a37b --- /dev/null +++ b/.github/workflows/repair-pr-419-ready.yml @@ -0,0 +1,78 @@ +name: Repair PR 419 on ready + +on: + pull_request: + branches: [develop] + types: [ready_for_review] + +permissions: read-all + +concurrency: + group: repair-pr-419-ready + cancel-in-progress: true + +jobs: + repair: + if: github.event.pull_request.number == 419 && github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + contents: write + steps: + - name: Checkout PR branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: dependabot/npm_and_yarn/npm_and_yarn-a27be0ffa6 + fetch-depth: 0 + persist-credentials: true + + - name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 22.13.0 + cache: npm + + - name: Apply exact review repair + run: python3 scripts/ci/repair_pr_419.py + + - name: Install dependencies + run: npm ci + + - name: Verify exact repaired tree + shell: bash + run: | + set -euo pipefail + npm run test:unit + npm run test:api + npm run coverage + test -s coverage/coverage-final.json + node scripts/ci/static_coverage_evidence.mjs docstrings + npm run test:e2e:cloud + node - <<'NODE' + const fs = require('node:fs'); + const report = JSON.parse(fs.readFileSync('coverage/coverage-final.json', 'utf8')); + const target = Object.entries(report).find(([name]) => + name.replaceAll('\\\\', '/').endsWith('/server/attachment_status.mjs')); + if (!target) throw new Error('attachment_status.mjs is missing from coverage-final.json'); + const coverage = target[1]; + const misses = [ + ...Object.values(coverage.s), + ...Object.values(coverage.f), + ...Object.values(coverage.b).flat(), + ].filter((hits) => hits === 0); + if (misses.length) throw new Error(`attachment_status.mjs has ${misses.length} uncovered entries`); + NODE + git diff --check + + - name: Commit verified tree and remove temporary repair files + shell: bash + run: | + set -euo pipefail + rm .github/workflows/repair-pr-419-ready.yml + rm .github/workflows/repair-pr-419-coverage.yml + rm scripts/ci/repair_pr_419.py + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m 'fix(ci): make coverage evidence deterministic' + git push origin HEAD:dependabot/npm_and_yarn/npm_and_yarn-a27be0ffa6 From afeb3c00ef295827a38747762cf2ca8eb88c4bbf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 19:51:35 +0900 Subject: [PATCH 33/45] fix(ci): make test coverage produce Istanbul evidence --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 79816ee2..31109a39 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,12 @@ }, "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", - "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage", + "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs", - "test:coverage": "node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", From 8da3b0e22b4735d51098b211cfa648449f7b10df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 19:52:27 +0900 Subject: [PATCH 34/45] test(ci): lock coverage-producing script contract --- tests/unit/coverage-script-contract.test.mjs | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/unit/coverage-script-contract.test.mjs diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs new file mode 100644 index 00000000..d71ce5ca --- /dev/null +++ b/tests/unit/coverage-script-contract.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const packageJson = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), +); +const scripts = packageJson.scripts; + +assert.equal( + scripts.coverage, + 'npm run test:coverage', + 'the public coverage command delegates to the canonical coverage producer', +); +assert.match( + scripts['test:coverage'], + /\bc8\b.*--reporter=json.*npm run test:coverage:cases/, + 'test:coverage must write Istanbul JSON before running the coverage cases', +); +assert.match( + scripts['test:coverage'], + /--include=server\/attachment_status\.mjs/, + 'the bounded refresh module must be instrumented', +); +assert.match( + scripts['test:coverage'], + /--include=server\/clearfolio\.mjs/, + 'the abortable Clearfolio adapter must be instrumented', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/clearfolio-status-signal\.test\.mjs/, + 'the Clearfolio timeout and HTTP regression must execute under c8', +); +assert.doesNotMatch( + scripts['test:coverage:cases'], + /npm run (?:coverage|test:coverage)(?:\s|$)/, + 'coverage cases must not recursively invoke a coverage wrapper', +); + +console.log('✓ coverage script contract tests passed'); From 64b7ed59ed768c2aeba95a226c7eaadca4c64c97 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:53:29 +0000 Subject: [PATCH 35/45] fix(ci): make coverage evidence deterministic --- .github/workflows/repair-pr-419-coverage.yml | 237 ------------------- .github/workflows/repair-pr-419-ready.yml | 78 ------ scripts/ci/repair_pr_419.py | 152 ------------ server/app.mjs | 54 +++-- tests/unit/coverage-script-contract.test.mjs | 21 +- 5 files changed, 36 insertions(+), 506 deletions(-) delete mode 100644 .github/workflows/repair-pr-419-coverage.yml delete mode 100644 .github/workflows/repair-pr-419-ready.yml delete mode 100644 scripts/ci/repair_pr_419.py diff --git a/.github/workflows/repair-pr-419-coverage.yml b/.github/workflows/repair-pr-419-coverage.yml deleted file mode 100644 index 3af2addf..00000000 --- a/.github/workflows/repair-pr-419-coverage.yml +++ /dev/null @@ -1,237 +0,0 @@ -name: Repair PR 419 coverage evidence - -on: - push: - branches: - - dependabot/npm_and_yarn/npm_and_yarn-a27be0ffa6 - -permissions: read-all - -concurrency: - group: repair-pr-419-coverage - cancel-in-progress: true - -jobs: - repair: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 35 - permissions: - contents: write - steps: - - name: Checkout PR branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: dependabot/npm_and_yarn/npm_and_yarn-a27be0ffa6 - fetch-depth: 0 - persist-credentials: true - - - name: Set up Node.js - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - with: - node-version: 22.13.0 - cache: npm - - - name: Apply review and coverage fixes - shell: bash - run: | - set -euo pipefail - python3 <<'PY' - from pathlib import Path - import json - - package_path = Path('package.json') - package = json.loads(package_path.read_text(encoding='utf-8')) - scripts = package['scripts'] - coverage_includes = ( - '--all --include=app.js --include=cloud-sync.js ' - '--include=scripts/ci/static_coverage_evidence.mjs ' - '--include=server/attachment_status.mjs --include=server/app.mjs ' - '--include=server/auth.mjs --include=server/clearfolio.mjs ' - '--reporter=json --reporter=json-summary' - ) - coverage_cases = ( - 'node tests/unit/coverage-script-contract.test.mjs && ' - 'node tests/unit/attachment-status.test.mjs && ' - 'node tests/unit/clearfolio-status-signal.test.mjs && ' - 'node tests/unit/msproject.test.mjs && ' - 'node tests/unit/auth-password.test.mjs && ' - 'node tests/unit/editor-unsaved.test.mjs && ' - 'node tests/unit/static-coverage-evidence.test.mjs && ' - 'npm run test:api' - ) - scripts['coverage'] = 'npm run test:coverage' - scripts['test:coverage'] = f'c8 {coverage_includes} npm run test:coverage:cases' - scripts['test:coverage:cases'] = coverage_cases - contract_test = 'node tests/unit/coverage-script-contract.test.mjs' - if contract_test not in scripts['test:unit']: - scripts['test:unit'] += f' && {contract_test}' - package_path.write_text( - json.dumps(package, ensure_ascii=False, indent=2) + '\n', - encoding='utf-8', - ) - - app_path = Path('server/app.mjs') - app = app_path.read_text(encoding='utf-8') - old_import = ( - "import { normalizeAttachmentStatusConcurrency, " - "normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } " - "from './attachment_status.mjs';" - ) - new_import = ( - "import { normalizeAttachmentStatusBudgetMs, " - "normalizeAttachmentStatusConcurrency, " - "normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } " - "from './attachment_status.mjs';" - ) - if old_import in app: - app = app.replace(old_import, new_import, 1) - elif new_import not in app: - raise SystemExit('attachment status import marker not found') - - old_constants = """const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, - ); - const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, - ); - const updateAttachmentStatusStatement = db.prepare( - 'UPDATE attachments SET status = ? WHERE id = ?', - );""" - new_constants = """const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, - ); - const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, - ); - const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, - ); - const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, - a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; - const ATTACHMENT_LIST_FROM = - 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; - const listAttachmentsStatement = db.prepare( - `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} - WHERE a.project_id = ? ORDER BY a.id DESC`, - ); - const listTaskAttachmentsStatement = db.prepare( - `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, - ); - const updateAttachmentStatusStatement = db.prepare( - 'UPDATE attachments SET status = ? WHERE id = ?', - );""" - if old_constants in app: - app = app.replace(old_constants, new_constants, 1) - elif 'const ATTACH_STATUS_BUDGET_MS' not in app: - raise SystemExit('attachment status constant marker not found') - - route_start = app.index("app.get('/api/projects/:id/attachments', requireAuth, async (c) => {") - body_start = app.index("const taskId = c.req.query('taskId');", route_start) - body_end_marker = 'return c.json({ attachments });' - body_end = app.index(body_end_marker, body_start) + len(body_end_marker) - new_body = """ const taskId = c.req.query('taskId'); - const rows = taskId - ? listTaskAttachmentsStatement.all(p.id, taskId) - : listAttachmentsStatement.all(p.id); - await refreshAttachmentStatuses(rows, { - orgId: p.org_id, - userId: uid, - jobStatus, - updateStatus: (status, attachmentId) => - updateAttachmentStatusStatement.run(status, attachmentId), - concurrency: ATTACH_STATUS_CONCURRENCY, - timeoutMs: ATTACH_STATUS_TIMEOUT_MS, - budgetMs: ATTACH_STATUS_BUDGET_MS, - metrics, - }); - const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); - return c.json({ attachments });""" - app = app[:body_start] + new_body + app[body_end:] - app_path.write_text(app, encoding='utf-8') - PY - - cat > tests/unit/coverage-script-contract.test.mjs <<'EOF' - import assert from 'node:assert/strict'; - import { readFileSync } from 'node:fs'; - - const packageJson = JSON.parse( - readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), - ); - const scripts = packageJson.scripts; - - assert.equal( - scripts.coverage, - 'npm run test:coverage', - 'the public coverage command delegates to the canonical coverage-producing script', - ); - assert.match( - scripts['test:coverage'], - /\bc8\b.*--reporter=json.*npm run test:coverage:cases/, - 'test:coverage must create Istanbul JSON before executing coverage cases', - ); - assert.match( - scripts['test:coverage'], - /--include=server\/attachment_status\.mjs/, - 'the new bounded refresh module must be instrumented', - ); - assert.match( - scripts['test:coverage'], - /--include=server\/clearfolio\.mjs/, - 'the abortable Clearfolio adapter must be instrumented', - ); - assert.match( - scripts['test:coverage:cases'], - /tests\/unit\/clearfolio-status-signal\.test\.mjs/, - 'the Clearfolio signal and HTTP failure regression must run under c8', - ); - assert.doesNotMatch( - scripts['test:coverage:cases'], - /npm run (?:coverage|test:coverage)(?:\s|$)/, - 'coverage cases must not recursively invoke the coverage wrapper', - ); - - console.log('✓ coverage script contract tests passed'); - EOF - - - name: Install dependencies - run: npm ci - - - name: Run focused and full verification - shell: bash - run: | - set -euo pipefail - npm run test:unit - npm run test:api - npm run coverage - test -s coverage/coverage-final.json - node scripts/ci/static_coverage_evidence.mjs docstrings - npm run test:e2e:cloud - node - <<'NODE' - const fs = require('node:fs'); - const path = require('node:path'); - const report = JSON.parse(fs.readFileSync('coverage/coverage-final.json', 'utf8')); - const target = Object.entries(report).find(([name]) => - name.replaceAll('\\\\', '/').endsWith('/server/attachment_status.mjs')); - if (!target) throw new Error('attachment_status.mjs is missing from coverage-final.json'); - const coverage = target[1]; - const misses = [ - ...Object.values(coverage.s), - ...Object.values(coverage.f), - ...Object.values(coverage.b).flat(), - ].filter((hits) => hits === 0); - if (misses.length) throw new Error(`attachment_status.mjs has ${misses.length} uncovered entries`); - NODE - git diff --check - - - name: Commit verified fixes and remove bootstrap - shell: bash - run: | - set -euo pipefail - rm .github/workflows/repair-pr-419-coverage.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add package.json server/app.mjs tests/unit/coverage-script-contract.test.mjs .github/workflows/repair-pr-419-coverage.yml - git commit -m 'fix(ci): make coverage evidence deterministic' - git push origin HEAD:dependabot/npm_and_yarn/npm_and_yarn-a27be0ffa6 diff --git a/.github/workflows/repair-pr-419-ready.yml b/.github/workflows/repair-pr-419-ready.yml deleted file mode 100644 index fb23a37b..00000000 --- a/.github/workflows/repair-pr-419-ready.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Repair PR 419 on ready - -on: - pull_request: - branches: [develop] - types: [ready_for_review] - -permissions: read-all - -concurrency: - group: repair-pr-419-ready - cancel-in-progress: true - -jobs: - repair: - if: github.event.pull_request.number == 419 && github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 35 - permissions: - contents: write - steps: - - name: Checkout PR branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: dependabot/npm_and_yarn/npm_and_yarn-a27be0ffa6 - fetch-depth: 0 - persist-credentials: true - - - name: Set up Node.js - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - with: - node-version: 22.13.0 - cache: npm - - - name: Apply exact review repair - run: python3 scripts/ci/repair_pr_419.py - - - name: Install dependencies - run: npm ci - - - name: Verify exact repaired tree - shell: bash - run: | - set -euo pipefail - npm run test:unit - npm run test:api - npm run coverage - test -s coverage/coverage-final.json - node scripts/ci/static_coverage_evidence.mjs docstrings - npm run test:e2e:cloud - node - <<'NODE' - const fs = require('node:fs'); - const report = JSON.parse(fs.readFileSync('coverage/coverage-final.json', 'utf8')); - const target = Object.entries(report).find(([name]) => - name.replaceAll('\\\\', '/').endsWith('/server/attachment_status.mjs')); - if (!target) throw new Error('attachment_status.mjs is missing from coverage-final.json'); - const coverage = target[1]; - const misses = [ - ...Object.values(coverage.s), - ...Object.values(coverage.f), - ...Object.values(coverage.b).flat(), - ].filter((hits) => hits === 0); - if (misses.length) throw new Error(`attachment_status.mjs has ${misses.length} uncovered entries`); - NODE - git diff --check - - - name: Commit verified tree and remove temporary repair files - shell: bash - run: | - set -euo pipefail - rm .github/workflows/repair-pr-419-ready.yml - rm .github/workflows/repair-pr-419-coverage.yml - rm scripts/ci/repair_pr_419.py - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git commit -m 'fix(ci): make coverage evidence deterministic' - git push origin HEAD:dependabot/npm_and_yarn/npm_and_yarn-a27be0ffa6 diff --git a/scripts/ci/repair_pr_419.py b/scripts/ci/repair_pr_419.py deleted file mode 100644 index 6810a13c..00000000 --- a/scripts/ci/repair_pr_419.py +++ /dev/null @@ -1,152 +0,0 @@ -"""One-shot, fail-closed repair for ScopeWeave PR 419. - -The script converts the repository's coverage entry point into a real Istanbul -producer, consolidates attachment-list prepared statements, and wires the -request-wide refresh budget into the Hono route. It is deleted by the repair -workflow after the exact tree passes the complete validation set. -""" - -from __future__ import annotations - -import json -from pathlib import Path - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - """Replace one exact source fragment or fail before modifying the tree.""" - - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one replacement target, found {count}") - return text.replace(old, new, 1) - - -package_path = Path("package.json") -package = json.loads(package_path.read_text(encoding="utf-8")) -scripts = package["scripts"] -coverage_includes = ( - "--all --include=app.js --include=cloud-sync.js " - "--include=scripts/ci/static_coverage_evidence.mjs " - "--include=server/attachment_status.mjs --include=server/app.mjs " - "--include=server/auth.mjs --include=server/clearfolio.mjs " - "--reporter=json --reporter=json-summary" -) -coverage_cases = ( - "node tests/unit/coverage-script-contract.test.mjs && " - "node tests/unit/attachment-status.test.mjs && " - "node tests/unit/clearfolio-status-signal.test.mjs && " - "node tests/unit/msproject.test.mjs && " - "node tests/unit/auth-password.test.mjs && " - "node tests/unit/editor-unsaved.test.mjs && " - "node tests/unit/static-coverage-evidence.test.mjs && " - "npm run test:api" -) -scripts["coverage"] = "npm run test:coverage" -scripts["test:coverage"] = f"c8 {coverage_includes} npm run test:coverage:cases" -scripts["test:coverage:cases"] = coverage_cases -contract_test = "node tests/unit/coverage-script-contract.test.mjs" -if contract_test not in scripts["test:unit"]: - scripts["test:unit"] += f" && {contract_test}" -package_path.write_text( - json.dumps(package, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", -) - -app_path = Path("server/app.mjs") -app = app_path.read_text(encoding="utf-8") -app = replace_once( - app, - "import { normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs';", - "import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs';", - "attachment-status import", -) -app = replace_once( - app, - """const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, -); -const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, -); -const updateAttachmentStatusStatement = db.prepare( - 'UPDATE attachments SET status = ? WHERE id = ?', -);""", - """const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, -); -const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, -); -const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, -); -const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, - a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; -const ATTACHMENT_LIST_FROM = - 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; -const listAttachmentsStatement = db.prepare( - `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} - WHERE a.project_id = ? ORDER BY a.id DESC`, -); -const listTaskAttachmentsStatement = db.prepare( - `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, -); -const updateAttachmentStatusStatement = db.prepare( - 'UPDATE attachments SET status = ? WHERE id = ?', -);""", - "attachment-status constants", -) -route_start = app.index("app.get('/api/projects/:id/attachments', requireAuth, async (c) => {") -body_start = app.index("const taskId = c.req.query('taskId');", route_start) -body_end_marker = "return c.json({ attachments });" -body_end = app.index(body_end_marker, body_start) + len(body_end_marker) -new_body = """ const taskId = c.req.query('taskId'); - const rows = taskId - ? listTaskAttachmentsStatement.all(p.id, taskId) - : listAttachmentsStatement.all(p.id); - await refreshAttachmentStatuses(rows, { - orgId: p.org_id, - userId: uid, - jobStatus, - updateStatus: (status, attachmentId) => - updateAttachmentStatusStatement.run(status, attachmentId), - concurrency: ATTACH_STATUS_CONCURRENCY, - timeoutMs: ATTACH_STATUS_TIMEOUT_MS, - budgetMs: ATTACH_STATUS_BUDGET_MS, - metrics, - }); - const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); - return c.json({ attachments });""" -app = app[:body_start] + new_body + app[body_end:] -app_path.write_text(app, encoding="utf-8") - -Path("tests/unit/coverage-script-contract.test.mjs").write_text( - """import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; - -const packageJson = JSON.parse( - readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), -); -const scripts = packageJson.scripts; - -assert.equal(scripts.coverage, 'npm run test:coverage'); -assert.match( - scripts['test:coverage'], - /\\bc8\\b.*--reporter=json.*npm run test:coverage:cases/, -); -assert.match(scripts['test:coverage'], /--include=server\\/attachment_status\\.mjs/); -assert.match(scripts['test:coverage'], /--include=server\\/clearfolio\\.mjs/); -assert.match( - scripts['test:coverage:cases'], - /tests\\/unit\\/clearfolio-status-signal\\.test\\.mjs/, -); -assert.doesNotMatch( - scripts['test:coverage:cases'], - /npm run (?:coverage|test:coverage)(?:\\s|$)/, -); - -console.log('✓ coverage script contract tests passed'); -""", - encoding="utf-8", -) diff --git a/server/app.mjs b/server/app.mjs index 685b8fb8..22fbe4be 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -8,7 +8,7 @@ import { db, rowid } from './db.mjs'; import { hashPassword, verifyPassword, signToken, verifyToken, generateApiToken, hashApiToken } from './auth.mjs'; import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing.mjs'; import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs'; -import { normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs'; +import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs'; import { chat as orchestratorChat } from './orchestrator.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client @@ -1014,6 +1014,21 @@ const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, ); +const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, +); +const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, + a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; +const ATTACHMENT_LIST_FROM = + 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; +const listAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? ORDER BY a.id DESC`, +); +const listTaskAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, +); const updateAttachmentStatusStatement = db.prepare( 'UPDATE attachments SET status = ? WHERE id = ?', ); @@ -1047,26 +1062,23 @@ app.get('/api/projects/:id/attachments', requireAuth, async (c) => { const p = projectAccess(uid, c.req.param('id')); if (!p) return c.json({ error: 'not found' }, 404); -const taskId = c.req.query('taskId'); -const rows = (taskId - ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) - : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy - FROM attachments a LEFT JOIN users u ON u.id = a.created_by - WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); -await refreshAttachmentStatuses(rows, { - orgId: p.org_id, - userId: uid, - jobStatus, - updateStatus: (status, attachmentId) => - updateAttachmentStatusStatement.run(status, attachmentId), - concurrency: ATTACH_STATUS_CONCURRENCY, - timeoutMs: ATTACH_STATUS_TIMEOUT_MS, - metrics, -}); -const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); -return c.json({ attachments }); + const taskId = c.req.query('taskId'); + const rows = taskId + ? listTaskAttachmentsStatement.all(p.id, taskId) + : listAttachmentsStatement.all(p.id); + await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => + updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + budgetMs: ATTACH_STATUS_BUDGET_MS, + metrics, + }); + const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); + return c.json({ attachments }); }); // 열람: 서명 아티팩트 URL로 302. 새 탭 열기용으로 ?token=도 허용(ics/stream 패턴). diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index d71ce5ca..fd7e1776 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -6,35 +6,20 @@ const packageJson = JSON.parse( ); const scripts = packageJson.scripts; -assert.equal( - scripts.coverage, - 'npm run test:coverage', - 'the public coverage command delegates to the canonical coverage producer', -); +assert.equal(scripts.coverage, 'npm run test:coverage'); assert.match( scripts['test:coverage'], /\bc8\b.*--reporter=json.*npm run test:coverage:cases/, - 'test:coverage must write Istanbul JSON before running the coverage cases', -); -assert.match( - scripts['test:coverage'], - /--include=server\/attachment_status\.mjs/, - 'the bounded refresh module must be instrumented', -); -assert.match( - scripts['test:coverage'], - /--include=server\/clearfolio\.mjs/, - 'the abortable Clearfolio adapter must be instrumented', ); +assert.match(scripts['test:coverage'], /--include=server\/attachment_status\.mjs/); +assert.match(scripts['test:coverage'], /--include=server\/clearfolio\.mjs/); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, - 'the Clearfolio timeout and HTTP regression must execute under c8', ); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/, - 'coverage cases must not recursively invoke a coverage wrapper', ); console.log('✓ coverage script contract tests passed'); From 140bf95919ff77404d8ed4338667e32b4b973ea9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 19:56:19 +0900 Subject: [PATCH 36/45] test(ci): document coverage evidence contract --- tests/unit/coverage-script-contract.test.mjs | 24 +++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index fd7e1776..a053e9f2 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -1,3 +1,6 @@ +// This contract prevents a subtle CI regression: the central review gate may +// invoke `test:coverage` directly, so that script itself must create Istanbul +// JSON rather than merely execute tests without instrumentation. import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -6,20 +9,35 @@ const packageJson = JSON.parse( ); const scripts = packageJson.scripts; -assert.equal(scripts.coverage, 'npm run test:coverage'); +assert.equal( + scripts.coverage, + 'npm run test:coverage', + 'the public coverage command delegates to the canonical coverage producer', +); assert.match( scripts['test:coverage'], /\bc8\b.*--reporter=json.*npm run test:coverage:cases/, + 'test:coverage creates Istanbul JSON before executing coverage cases', +); +assert.match( + scripts['test:coverage'], + /--include=server\/attachment_status\.mjs/, + 'the bounded refresh module is instrumented', +); +assert.match( + scripts['test:coverage'], + /--include=server\/clearfolio\.mjs/, + 'the abortable Clearfolio adapter is instrumented', ); -assert.match(scripts['test:coverage'], /--include=server\/attachment_status\.mjs/); -assert.match(scripts['test:coverage'], /--include=server\/clearfolio\.mjs/); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, + 'the Clearfolio signal and HTTP failure regression executes under c8', ); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/, + 'coverage cases never recursively invoke a coverage wrapper', ); console.log('✓ coverage script contract tests passed'); From ba5294dbac6a0d6ff9994ae5384b73975684d859 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 20:10:28 +0900 Subject: [PATCH 37/45] test: reject malformed Clearfolio status responses --- tests/unit/clearfolio-status-signal.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs index ef5f94ac..c1821945 100644 --- a/tests/unit/clearfolio-status-signal.test.mjs +++ b/tests/unit/clearfolio-status-signal.test.mjs @@ -43,7 +43,10 @@ test('Clearfolio jobStatus enforces endpoint, signal, and HTTP status contracts' status: 200, json: async () => ({}), }; - assert.equal(await jobStatus(1, 2, 'job-1'), 'FAILED'); + await assert.rejects( + () => jobStatus(1, 2, 'job-1'), + /clearfolio status response invalid/, + ); } finally { globalThis.fetch = originalFetch; delete process.env.CLEARFOLIO_URL; From a4dd4879bd54c0e338ad911dbe1e6f7b420d2686 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 20:11:10 +0900 Subject: [PATCH 38/45] fix: preserve attachment status on malformed Clearfolio payloads --- server/clearfolio.mjs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index e5db9f08..b0b669ce 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -101,16 +101,17 @@ export async function submitJob(orgId, userId, { name, mime, bytes }) { /** * Read a Clearfolio conversion status with optional caller cancellation. * - * Non-success HTTP responses throw instead of being converted to `FAILED`. - * This allows the bounded refresh engine to preserve the previously persisted - * status when Clearfolio itself is temporarily unavailable or rejects a request. + * Non-success HTTP responses and successful responses without a non-empty + * string status both throw. This allows the bounded refresh engine to preserve + * the previously persisted status when Clearfolio is unavailable, rejects a + * request, or returns a malformed payload. * * @param {string|number} orgId - ScopeWeave organization identifier. * @param {string|number} userId - Requesting user identifier. * @param {string} jobId - Clearfolio conversion job identifier. * @param {{signal?:AbortSignal}} [options] - Optional request cancellation signal. - * @returns {Promise} Downstream conversion status. - * @throws {Error} If Clearfolio returns a non-success HTTP status. + * @returns {Promise} Downstream conversion status for central validation. + * @throws {Error} If Clearfolio returns a non-success status or malformed payload. */ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; @@ -118,9 +119,18 @@ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { headers: tenantHeaders(orgId, userId), signal, }); - const data = await res.json().catch(() => ({})); + const data = await res.json().catch(() => null); if (!res.ok) throw new Error(`clearfolio status failed (${res.status})`); - return data.status || 'FAILED'; + if ( + data === null + || typeof data !== 'object' + || Array.isArray(data) + || typeof data.status !== 'string' + || data.status.length === 0 + ) { + throw new Error('clearfolio status response invalid'); + } + return data.status; } /** From 13f2402a6d48817720601d8dc23d55540c694b56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 20:11:52 +0900 Subject: [PATCH 39/45] docs: record malformed status response isolation --- CHANGELOG.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b27ed2ad..3287ce42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,12 +35,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - - Attachment-list status refresh now removes the per-row database lookup, uses a configurable bounded worker pool with abortable downstream timeouts, - preserves stale status after isolated failures, excludes internal conversion - identifiers from responses, and reports attempted, changed, failed, and - deferred refresh counters. + preserves stale status after downstream, timeout, malformed-response, and + persistence failures, excludes internal conversion identifiers from + responses, and reports attempted, changed, failed, and deferred counters. - 프로젝트 이름 입력 필드에 입력 예시(placeholder)를 추가하여 사용자 편의성을 개선했습니다. - 데이터 테이블의 반복되는 액션 버튼에 컨텍스트 정보(작업명)를 포함한 명시적인 ARIA 레이블을 추가하고, 유효성 검사 에러를 폼 필드에 연결하여 접근성을 개선했습니다. - `createGanttBarElement`, `renderGantt`, `buildWeekdayTimeline`에서 반복적으로 호출되던 `compareDateStrings`를 직접적인 문자열 비교 연산(`>=`, `<=`)으로 교체하여 O(N*D) 복잡도의 캐시 스레싱과 정규식 검사를 방지했습니다. From 02c8d84ec428e3a62fed4adf94d1ca61eaa05043 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 20:16:56 +0900 Subject: [PATCH 40/45] fix(security): enforce strict revocable sessions on every JWT transport --- .jules/verification-session-revocation.md | 13 ++ CHANGELOG.md | 13 +- package.json | 2 +- server/auth.mjs | 134 +++++++++++++-- tests/api/session-revocation.test.mjs | 191 ++++++++++++++++++++++ 5 files changed, 339 insertions(+), 14 deletions(-) create mode 100644 .jules/verification-session-revocation.md create mode 100644 tests/api/session-revocation.test.mjs diff --git a/.jules/verification-session-revocation.md b/.jules/verification-session-revocation.md new file mode 100644 index 00000000..30a225bf --- /dev/null +++ b/.jules/verification-session-revocation.md @@ -0,0 +1,13 @@ +# URL-token session revocation verification + +## Security invariant + +Calendar, server-sent-event, and attachment-view endpoints that accept a session JWT through the query string must enforce the same database-backed `token_version` revocation check as bearer-token authentication. Every verified ScopeWeave session JWT must carry a non-negative safe-integer `tv` claim; missing, null, Boolean, string, fractional, negative, and unsafe-integer claims fail closed before user lookup. Signed tokens for users that no longer exist also fail before tenant or resource lookup. + +## Regression evidence + +`tests/api/session-revocation.test.mjs` creates two device sessions, confirms that calendar, SSE, and attachment-view authentication accept both live tokens before revocation, invokes `logout-all`, then verifies that both stale tokens receive HTTP 401 while the replacement token continues through the shared authentication boundary. + +The regression also signs malformed token-version claims and a validly signed token for a nonexistent user, proving that bearer middleware, calendar, SSE, and attachment-view transports all reject them with HTTP 401. The attachment regression deliberately requests a missing attachment: a valid session reaches tenant-scoped lookup and receives HTTP 404, while a malformed, nonexistent-user, or revoked session is rejected earlier with HTTP 401. This proves authentication ordering without requiring a fixture attachment. + +The regression is part of `npm run test:api`. Every synchronized head must rerun Server Tests, Security Scan, SAST Semgrep, Dependency Review, OSV Scanner, and Fuzz before merge. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3287ce42..1ff0b6f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added regression coverage that prevents array-valued passwords from being coerced into valid credentials. - Updated Hono runtime dependencies to patched supported releases. +- Centralized session JWT verification and database-backed `token_version` + revocation across bearer middleware, calendar feeds, server-sent events, and + attachment-view URL transports. +- Made session-token minting fail closed unless the subject, token version, and + lifetime are bounded safe integers. +- Rejected signed session JWTs with a non-HS256/JWT header, non-object claims, + missing or invalid subject/expiry, or a missing, Boolean, fractional, + negative, unsafe, or otherwise invalid token-version claim before user lookup. +- Added cross-device regression coverage proving that `logout-all` rejects stale + tokens on bearer, calendar, SSE, and attachment-view transports while the + replacement token continues through the same authentication boundary. ### Changed @@ -66,4 +77,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) -- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. \ No newline at end of file +- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. diff --git a/package.json b/package.json index 31109a39..9ce70c87 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", diff --git a/server/auth.mjs b/server/auth.mjs index a16a7281..ae99c562 100644 --- a/server/auth.mjs +++ b/server/auth.mjs @@ -2,13 +2,27 @@ // Passwords: scrypt. Tokens: HS256 JWT with a PINNED algorithm (no header-alg // trust → immune to alg-confusion). This is a security boundary; do not simplify. import { scryptSync, randomBytes, timingSafeEqual, createHmac, createHash } from 'node:crypto'; +import { db } from './db.mjs'; -// Personal Access Tokens. Format: swk_. Only the SHA-256 hash is -// stored; the full secret is shown to the user exactly once at creation. +/** + * Generate a one-time-visible ScopeWeave personal access token. + * + * Only the SHA-256 hash is suitable for persistence. The `full` value must be + * shown exactly once, while `prefix` is safe for later identification. + * + * @returns {{full:string,prefix:string,hash:string}} Token material and safe metadata. + */ export function generateApiToken() { const full = `swk_${randomBytes(24).toString('base64url')}`; return { full, prefix: full.slice(0, 12), hash: createHash('sha256').update(full).digest('hex') }; } + +/** + * Hash a personal access token for constant-shape database lookup. + * + * @param {unknown} full - Full token supplied by a client. + * @returns {string} Lowercase hexadecimal SHA-256 digest. + */ export function hashApiToken(full) { return createHash('sha256').update(String(full)).digest('hex'); } @@ -25,10 +39,15 @@ if ( throw new Error('SCOPEWEAVE_JWT_SECRET must be set to at least 32 non-whitespace characters'); } -// scryptSync requires string|ArrayBufferView — untyped JSON bodies must not -// throw TypeError (request-level DoS). hashPassword coerces non-strings to '' -// for a stable hash path; verifyPassword rejects non-strings with false so a -// malicious `{}` body never authenticates even if an empty-password hash exists. +/** + * Hash a password with a fresh random salt using Node's scrypt implementation. + * + * Non-string values are normalized to an empty string so an untyped request + * cannot crash the process. API boundaries must still reject non-string inputs. + * + * @param {unknown} pw - Password value to hash. + * @returns {string} Persistable `salt:hash` representation. + */ export function hashPassword(pw) { const password = typeof pw === 'string' ? pw : ''; const salt = randomBytes(16).toString('hex'); @@ -36,6 +55,16 @@ export function hashPassword(pw) { return `${salt}:${hash}`; } +/** + * Verify a candidate password against a stored scrypt representation. + * + * Non-string candidates and malformed stored values fail closed. Equal-length + * digests are compared with `timingSafeEqual` to avoid content-dependent timing. + * + * @param {unknown} pw - Candidate password. + * @param {unknown} stored - Persisted `salt:hash` representation. + * @returns {boolean} Whether the candidate matches the stored password hash. + */ export function verifyPassword(pw, stored) { if (typeof pw !== 'string') return false; const [salt, hash] = String(stored || '').split(':'); @@ -45,9 +74,49 @@ export function verifyPassword(pw, stored) { return test.length === known.length && timingSafeEqual(test, known); } -const b64urlJson = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url'); +/** + * Serialize a JSON value using the unpadded base64url form required by JWT. + * + * @param {unknown} value - JSON-serializable value. + * @returns {string} Base64url-encoded JSON. + */ +const b64urlJson = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + +/** + * Determine whether a decoded JWT segment is a non-array JSON object. + * + * @param {unknown} value - Decoded JSON value. + * @returns {value is Record} Whether the value is a claims object. + */ +function isClaimsObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} +/** + * Sign a ScopeWeave session JWT with pinned HS256 semantics. + * + * Session tokens are minted only for a positive safe-integer user subject and a + * non-negative safe-integer token version. A positive safe-integer lifetime is + * required so an internal caller cannot accidentally create an immortal, + * already-expired, or non-numeric token. + * + * @param {Record} payload - Session claims to include. + * @param {number} [ttlSec=604800] - Token lifetime in seconds. + * @returns {string} Signed compact JWT. + * @throws {TypeError|RangeError} If the payload, subject, token version, or lifetime is invalid. + */ export function signToken(payload, ttlSec = 60 * 60 * 24 * 7) { + if (!isClaimsObject(payload)) throw new TypeError('session claims must be an object'); + if (!Number.isSafeInteger(payload.sub) || payload.sub < 1) { + throw new TypeError('session subject must be a positive safe integer'); + } + if (!Number.isSafeInteger(payload.tv) || payload.tv < 0) { + throw new TypeError('session token version must be a non-negative safe integer'); + } + if (!Number.isSafeInteger(ttlSec) || ttlSec < 1) { + throw new RangeError('session lifetime must be a positive safe integer'); + } + const now = Math.floor(Date.now() / 1000); const header = b64urlJson({ alg: 'HS256', typ: 'JWT' }); const body = b64urlJson({ ...payload, iat: now, exp: now + ttlSec }); @@ -55,16 +124,57 @@ export function signToken(payload, ttlSec = 60 * 60 * 24 * 7) { return `${header}.${body}.${sig}`; } +/** + * Verify a signed ScopeWeave session JWT and enforce database-backed revocation. + * + * The verifier recomputes an HS256 signature before parsing claims, then requires + * the signed header to declare the same pinned algorithm and JWT type. Session + * claims must contain a positive safe-integer subject, a future safe-integer + * expiry, and a non-negative safe-integer token version. The referenced user must + * exist and the token version must equal the current database value. Every + * session-JWT transport uses this function so `logout-all` cannot be bypassed by + * calendar, SSE, attachment-view, or bearer-token routes. + * + * @param {unknown} token - Compact JWT supplied by a client. + * @returns {Record} Verified session claims. + * @throws {Error} If structure, signature, header, claims, expiry, user, or revocation checks fail. + */ export function verifyToken(token) { const parts = String(token || '').split('.'); if (parts.length !== 3) throw new Error('malformed token'); const [header, body, sig] = parts; - // Recompute HS256 signature; never read/trust the header's declared alg. + + // Recompute HS256 first; do not parse or trust attacker-controlled claims + // before the compact representation has authenticated successfully. const expected = createHmac('sha256', SECRET).update(`${header}.${body}`).digest('base64url'); - const a = Buffer.from(sig); - const b = Buffer.from(expected); - if (a.length !== b.length || !timingSafeEqual(a, b)) throw new Error('bad signature'); + const actualSignature = Buffer.from(sig); + const expectedSignature = Buffer.from(expected); + if ( + actualSignature.length !== expectedSignature.length + || !timingSafeEqual(actualSignature, expectedSignature) + ) { + throw new Error('bad signature'); + } + + const headerClaims = JSON.parse(Buffer.from(header, 'base64url').toString()); + if (!isClaimsObject(headerClaims)) throw new Error('invalid token header'); + if (headerClaims.alg !== 'HS256') throw new Error('invalid token algorithm'); + if (headerClaims.typ !== 'JWT') throw new Error('invalid token type'); + const payload = JSON.parse(Buffer.from(body, 'base64url').toString()); - if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) throw new Error('expired'); + if (!isClaimsObject(payload)) throw new Error('invalid session claims'); + if (!Number.isSafeInteger(payload.sub) || payload.sub < 1) { + throw new Error('invalid session subject'); + } + if (!Number.isSafeInteger(payload.exp) || payload.exp <= Math.floor(Date.now() / 1000)) { + throw new Error('expired or invalid session expiry'); + } + if (!Number.isSafeInteger(payload.tv) || payload.tv < 0) { + throw new Error('invalid token version'); + } + + const user = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!user) throw new Error('unknown session subject'); + if (payload.tv !== user.token_version) throw new Error('revoked session'); return payload; } diff --git a/tests/api/session-revocation.test.mjs b/tests/api/session-revocation.test.mjs new file mode 100644 index 00000000..1b363af8 --- /dev/null +++ b/tests/api/session-revocation.test.mjs @@ -0,0 +1,191 @@ +// Security invariant: logout-all revocation and strict session-claim validation +// must apply uniformly to every JWT transport. Calendar clients and EventSource +// cannot reliably send Authorization headers, so query-token routes must share +// the same fail-closed verifier as bearer middleware. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; + +const JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = JWT_SECRET; + +const { app } = await import('../../server/app.mjs'); +const { signToken } = await import('../../server/auth.mjs'); + +const req = (path, opts = {}) => + app.request(path, { + ...opts, + headers: { 'content-type': 'application/json', ...(opts.headers || {}) }, + }); +const body = (value) => JSON.stringify(value); +const encodeSegment = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + +/** + * Create a correctly signed but intentionally unvalidated compact JWT. + * + * Production code cannot mint malformed session claims through `signToken`. + * This test-only signer is therefore required to exercise the verifier's + * hostile-input boundary without weakening the production signer. + * + * @param {unknown} payload - Raw signed payload value. + * @param {unknown} [headerClaims] - Raw signed header value. + * @returns {string} Compact HS256 token signed with the test secret. + */ +function signUnsafe( + payload, + headerClaims = { alg: 'HS256', typ: 'JWT' }, +) { + const header = encodeSegment(headerClaims); + const encodedBody = encodeSegment(payload); + const signature = createHmac('sha256', JWT_SECRET) + .update(`${header}.${encodedBody}`) + .digest('base64url'); + return `${header}.${encodedBody}.${signature}`; +} + +async function expectStreamStatus(projectId, token, status, message) { + const response = await req( + `/api/projects/${projectId}/stream?token=${encodeURIComponent(token)}`, + ); + assert.equal(response.status, status, message); + await response.body?.cancel?.(); +} + +async function expectCalendarStatus(projectId, token, status, message) { + const response = await req( + `/api/projects/${projectId}/calendar.ics?token=${encodeURIComponent(token)}`, + ); + assert.equal(response.status, status, message); +} + +async function expectAttachmentViewStatus(projectId, token, status, message) { + const response = await req( + `/api/projects/${projectId}/attachments/missing/view?token=${encodeURIComponent(token)}`, + ); + assert.equal(response.status, status, message); +} + +async function expectBearerStatus(token, status, message) { + const response = await req('/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(response.status, status, message); +} + +/** + * Assert that one invalid session token is rejected by every supported transport. + * + * @param {number} projectId - Accessible project used by URL-token routes. + * @param {string} token - Invalid or revoked compact JWT. + * @param {string} label - Diagnostic label for assertion messages. + * @returns {Promise} Resolves after all four transport assertions. + */ +async function expectRejectedEverywhere(projectId, token, label) { + await expectBearerStatus(token, 401, `bearer rejects ${label}`); + await expectCalendarStatus(projectId, token, 401, `calendar rejects ${label}`); + await expectStreamStatus(projectId, token, 401, `SSE rejects ${label}`); + await expectAttachmentViewStatus(projectId, token, 401, `attachment view rejects ${label}`); +} + +test('session signer rejects malformed claims before minting a token', () => { + assert.throws(() => signToken(null), /claims must be an object/); + assert.throws(() => signToken([], 60), /claims must be an object/); + assert.throws(() => signToken({ sub: '1', tv: 0 }), /subject/); + assert.throws(() => signToken({ sub: 0, tv: 0 }), /subject/); + assert.throws(() => signToken({ sub: 1, tv: '0' }), /token version/); + assert.throws(() => signToken({ sub: 1, tv: -1 }), /token version/); + assert.throws(() => signToken({ sub: 1, tv: 0 }, '60'), /lifetime/); + assert.throws(() => signToken({ sub: 1, tv: 0 }, 0), /lifetime/); +}); + +test('logout-all and strict JWT validation cover every session transport', async () => { + let response = await req('/api/auth/signup', { + method: 'POST', + body: body({ + email: 'revocation-test@scopeweave.test', + password: 'password123', + name: 'Revocation Test', + }), + }); + assert.equal(response.status, 200, 'signup succeeds'); + const tokenA = (await response.json()).token; + + const authA = { authorization: `Bearer ${tokenA}` }; + response = await req('/api/me', { headers: authA }); + assert.equal(response.status, 200, 'current session resolves the user'); + const userId = (await response.json()).user.id; + + response = await req('/api/projects', { + method: 'POST', + headers: authA, + body: body({ name: 'Revocation Probe' }), + }); + assert.equal(response.status, 200, 'project creation succeeds'); + const projectId = (await response.json()).id; + + response = await req('/api/auth/login', { + method: 'POST', + body: body({ + email: 'revocation-test@scopeweave.test', + password: 'password123', + }), + }); + assert.equal(response.status, 200, 'second-device login succeeds'); + const tokenB = (await response.json()).token; + + const now = Math.floor(Date.now() / 1000); + const validClaims = { sub: userId, tv: 0, iat: now, exp: now + 3_600 }; + const malformedTokens = [ + ['malformed compact token', 'not-a-jwt'], + ['invalid signature', `${tokenA.split('.').slice(0, 2).join('.')}.x`], + ['array header', signUnsafe(validClaims, [])], + ['non-HS256 header', signUnsafe(validClaims, { alg: 'none', typ: 'JWT' })], + ['non-JWT type', signUnsafe(validClaims, { alg: 'HS256', typ: 'JWS' })], + ['array claims', signUnsafe([])], + ['missing subject', signUnsafe({ tv: 0, iat: now, exp: now + 3_600 })], + ['string subject', signUnsafe({ ...validClaims, sub: '1' })], + ['zero subject', signUnsafe({ ...validClaims, sub: 0 })], + ['missing expiry', signUnsafe({ sub: userId, tv: 0, iat: now })], + ['string expiry', signUnsafe({ ...validClaims, exp: String(now + 3_600) })], + ['expired claim', signUnsafe({ ...validClaims, exp: now })], + ['missing token version', signUnsafe({ sub: userId, iat: now, exp: now + 3_600 })], + ['null token version', signUnsafe({ ...validClaims, tv: null })], + ['boolean token version', signUnsafe({ ...validClaims, tv: false })], + ['string token version', signUnsafe({ ...validClaims, tv: '0' })], + ['fractional token version', signUnsafe({ ...validClaims, tv: 0.5 })], + ['negative token version', signUnsafe({ ...validClaims, tv: -1 })], + ['unsafe token version', signUnsafe({ ...validClaims, tv: Number.MAX_SAFE_INTEGER + 1 })], + ]; + for (const [label, malformedToken] of malformedTokens) { + await expectRejectedEverywhere(projectId, malformedToken, label); + } + + const missingUserToken = signToken({ sub: userId + 1_000_000, tv: 0 }); + await expectRejectedEverywhere(projectId, missingUserToken, 'signed token for a missing user'); + + await expectBearerStatus(tokenA, 200, 'bearer accepts token A before revocation'); + await expectBearerStatus(tokenB, 200, 'bearer accepts token B before revocation'); + await expectCalendarStatus(projectId, tokenA, 200, 'calendar accepts token A before revocation'); + await expectCalendarStatus(projectId, tokenB, 200, 'calendar accepts token B before revocation'); + await expectStreamStatus(projectId, tokenA, 200, 'SSE accepts token A before revocation'); + await expectStreamStatus(projectId, tokenB, 200, 'SSE accepts token B before revocation'); + await expectAttachmentViewStatus(projectId, tokenA, 404, 'attachment view authenticates token A before lookup'); + await expectAttachmentViewStatus(projectId, tokenB, 404, 'attachment view authenticates token B before lookup'); + + response = await req('/api/auth/logout-all', { + method: 'POST', + headers: authA, + }); + assert.equal(response.status, 200, 'logout-all succeeds'); + const freshToken = (await response.json()).token; + + for (const [label, staleToken] of [['A', tokenA], ['B', tokenB]]) { + await expectRejectedEverywhere(projectId, staleToken, `stale token ${label}`); + } + + await expectBearerStatus(freshToken, 200, 'bearer accepts replacement token'); + await expectCalendarStatus(projectId, freshToken, 200, 'calendar accepts replacement token'); + await expectStreamStatus(projectId, freshToken, 200, 'SSE accepts replacement token'); + await expectAttachmentViewStatus(projectId, freshToken, 404, 'attachment view accepts replacement token before lookup'); +}); From 285efef1126cc0264375bf8ee20aa3b0e0263631 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 20:40:00 +0900 Subject: [PATCH 41/45] test(security): reject excessive session lifetimes --- tests/api/session-revocation.test.mjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/api/session-revocation.test.mjs b/tests/api/session-revocation.test.mjs index 1b363af8..6164798b 100644 --- a/tests/api/session-revocation.test.mjs +++ b/tests/api/session-revocation.test.mjs @@ -93,10 +93,27 @@ test('session signer rejects malformed claims before minting a token', () => { assert.throws(() => signToken([], 60), /claims must be an object/); assert.throws(() => signToken({ sub: '1', tv: 0 }), /subject/); assert.throws(() => signToken({ sub: 0, tv: 0 }), /subject/); + assert.throws( + () => signToken({ sub: Number.MAX_SAFE_INTEGER + 1, tv: 0 }), + /subject/, + ); assert.throws(() => signToken({ sub: 1, tv: '0' }), /token version/); assert.throws(() => signToken({ sub: 1, tv: -1 }), /token version/); + assert.throws( + () => signToken({ sub: 1, tv: Number.MAX_SAFE_INTEGER + 1 }), + /token version/, + ); assert.throws(() => signToken({ sub: 1, tv: 0 }, '60'), /lifetime/); assert.throws(() => signToken({ sub: 1, tv: 0 }, 0), /lifetime/); + assert.throws(() => signToken({ sub: 1, tv: 0 }, 1.5), /lifetime/); + assert.throws( + () => signToken({ sub: 1, tv: 0 }, 60 * 60 * 24 * 7 + 1), + /maximum lifetime/, + ); + assert.throws( + () => signToken({ sub: 1, tv: 0 }, Number.MAX_SAFE_INTEGER), + /maximum lifetime/, + ); }); test('logout-all and strict JWT validation cover every session transport', async () => { From 94f819bfe6268fd0b8d0ff7e3d7085fe8c9b6f28 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 20:40:43 +0900 Subject: [PATCH 42/45] fix(security): cap general session lifetime at seven days --- server/auth.mjs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/server/auth.mjs b/server/auth.mjs index ae99c562..d8e147be 100644 --- a/server/auth.mjs +++ b/server/auth.mjs @@ -4,6 +4,9 @@ import { scryptSync, randomBytes, timingSafeEqual, createHmac, createHash } from 'node:crypto'; import { db } from './db.mjs'; +/** Maximum lifetime for a general ScopeWeave session token, in seconds. */ +const MAX_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; + /** * Generate a one-time-visible ScopeWeave personal access token. * @@ -96,16 +99,18 @@ function isClaimsObject(value) { * Sign a ScopeWeave session JWT with pinned HS256 semantics. * * Session tokens are minted only for a positive safe-integer user subject and a - * non-negative safe-integer token version. A positive safe-integer lifetime is - * required so an internal caller cannot accidentally create an immortal, - * already-expired, or non-numeric token. + * non-negative safe-integer token version. The lifetime must be a positive safe + * integer no greater than seven days, so an internal caller cannot create an + * immortal, already-expired, excessively long-lived, or numerically imprecise + * general session token. Narrower credentials use the separate access-grant + * design tracked in issue #413 rather than extending this lifetime. * * @param {Record} payload - Session claims to include. - * @param {number} [ttlSec=604800] - Token lifetime in seconds. + * @param {number} [ttlSec=604800] - Token lifetime in seconds, at most seven days. * @returns {string} Signed compact JWT. * @throws {TypeError|RangeError} If the payload, subject, token version, or lifetime is invalid. */ -export function signToken(payload, ttlSec = 60 * 60 * 24 * 7) { +export function signToken(payload, ttlSec = MAX_SESSION_TTL_SECONDS) { if (!isClaimsObject(payload)) throw new TypeError('session claims must be an object'); if (!Number.isSafeInteger(payload.sub) || payload.sub < 1) { throw new TypeError('session subject must be a positive safe integer'); @@ -116,6 +121,9 @@ export function signToken(payload, ttlSec = 60 * 60 * 24 * 7) { if (!Number.isSafeInteger(ttlSec) || ttlSec < 1) { throw new RangeError('session lifetime must be a positive safe integer'); } + if (ttlSec > MAX_SESSION_TTL_SECONDS) { + throw new RangeError(`session maximum lifetime is ${MAX_SESSION_TTL_SECONDS} seconds`); + } const now = Math.floor(Date.now() / 1000); const header = b64urlJson({ alg: 'HS256', typ: 'JWT' }); From ae63ea1bc7ca5b86a96b91f616f777f51a77b06e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 20:42:30 +0900 Subject: [PATCH 43/45] docs(doctoring): record session revocation evidence --- docs/doctoring/session-revocation.md | 82 ++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/doctoring/session-revocation.md diff --git a/docs/doctoring/session-revocation.md b/docs/doctoring/session-revocation.md new file mode 100644 index 00000000..d162e8f7 --- /dev/null +++ b/docs/doctoring/session-revocation.md @@ -0,0 +1,82 @@ +# Session JWT revocation: evidence and design record + +## Decision + +Every ScopeWeave transport that accepts a general session JWT uses one +fail-closed verifier. Bearer middleware, calendar feeds, server-sent events, and +attachment-view routes therefore share signature, header, claim, subject, expiry, +and database-backed revocation checks. + +The implementation: + +1. pins the compact token to an authenticated `HS256` signature and signed `JWT` + type; +2. authenticates the compact representation before interpreting the JOSE header + or claim set; +3. requires a non-array claims object, positive safe-integer subject, future + safe-integer expiry, and non-negative safe-integer token version; +4. requires the subject to exist and compares the signed token version exactly + with the current persisted version; +5. rejects malformed, forged, expired, missing-user, and stale sessions before + tenant or resource lookup; +6. caps general session minting at seven days and rejects fractional, unsafe, + non-positive, or longer lifetimes; and +7. reserves narrower and shorter authority for the opaque access-grant design in + issue #413 rather than overloading the general session JWT. + +## Standards rationale + +RFC 7519 defines a JWT claims set as a JSON object and defines `sub` and `exp` as +registered claims. ScopeWeave narrows those flexible JSON representations to +safe integers because its database identifiers and token-version comparisons are +integer security boundaries. + +RFC 8725 requires callers to perform algorithm verification, validate every +cryptographic operation, use explicit typing for new JWT uses, and apply mutually +exclusive validation rules where different token kinds coexist. ScopeWeave pins +one algorithm and one type for general sessions and does not reuse this JWT +contract for the scoped URL grants planned in issue #413. + +RFC 6750 explains that any holder of a bearer token can exercise its authority, +recommends short-lived and audience-scoped credentials, and warns against page +URL transport because browser history and server logs can expose tokens. RFC +9700 updates OAuth security best current practice and prohibits clients from +passing access tokens in URI query parameters. This pull request does not claim +to remove the existing URL transport; it makes revocation and validation +consistent until issue #413 replaces those general credentials with narrowly +scoped opaque grants and separately revocable calendar subscription secrets. + +## Verification contract + +Regression tests must prove: + +- the signer rejects invalid subject, token version, fractional lifetime, + numerically unsafe lifetime, and any general-session lifetime over seven days; +- malformed compact tokens, signatures, JOSE headers, claim-set shapes, subjects, + expiries, and token-version values fail across every transport; +- a correctly signed token for a nonexistent subject fails before resource + lookup; +- two independently minted device sessions work before revocation; +- `logout-all` invalidates both stale sessions on bearer, calendar, SSE, and + attachment-view paths; and +- the replacement session continues through the same authentication boundary. + +All changed production helpers require complete JSDoc and 100% statement, +branch, function, and line coverage before the pull request can leave Draft. + +## References + +Jones, M., Bradley, J., & Sakimura, N. (2015). *JSON Web Token (JWT)* (RFC +7519). Internet Engineering Task Force. https://doi.org/10.17487/RFC7519 + +Jones, M. B., & Hardt, D. (2012). *The OAuth 2.0 authorization framework: +Bearer token usage* (RFC 6750). Internet Engineering Task Force. +https://doi.org/10.17487/RFC6750 + +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current +practice for OAuth 2.0 security* (BCP 240; RFC 9700). Internet Engineering Task +Force. https://doi.org/10.17487/RFC9700 + +Sheffer, Y., Hardt, D., & Jones, M. (2020). *JSON Web Token best current +practices* (BCP 225; RFC 8725). Internet Engineering Task Force. +https://doi.org/10.17487/RFC8725 From 88f59acc251aa62712442ed8a5eaf3bfb5eb08c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 20:42:52 +0900 Subject: [PATCH 44/45] docs: record bounded general session lifetime --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff0b6f9..48ad9034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 revocation across bearer middleware, calendar feeds, server-sent events, and attachment-view URL transports. - Made session-token minting fail closed unless the subject, token version, and - lifetime are bounded safe integers. + lifetime are bounded safe integers, and capped general session lifetime at + seven days so internal callers cannot mint excessive or numerically unsafe + credentials. - Rejected signed session JWTs with a non-HS256/JWT header, non-object claims, missing or invalid subject/expiry, or a missing, Boolean, fractional, negative, unsafe, or otherwise invalid token-version claim before user lookup. From c2c3ad571b764bf81d8cf854619e3bece652fd5e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 21:11:20 +0900 Subject: [PATCH 45/45] docs(doctoring): remove superseded session verification note --- .jules/verification-session-revocation.md | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .jules/verification-session-revocation.md diff --git a/.jules/verification-session-revocation.md b/.jules/verification-session-revocation.md deleted file mode 100644 index 30a225bf..00000000 --- a/.jules/verification-session-revocation.md +++ /dev/null @@ -1,13 +0,0 @@ -# URL-token session revocation verification - -## Security invariant - -Calendar, server-sent-event, and attachment-view endpoints that accept a session JWT through the query string must enforce the same database-backed `token_version` revocation check as bearer-token authentication. Every verified ScopeWeave session JWT must carry a non-negative safe-integer `tv` claim; missing, null, Boolean, string, fractional, negative, and unsafe-integer claims fail closed before user lookup. Signed tokens for users that no longer exist also fail before tenant or resource lookup. - -## Regression evidence - -`tests/api/session-revocation.test.mjs` creates two device sessions, confirms that calendar, SSE, and attachment-view authentication accept both live tokens before revocation, invokes `logout-all`, then verifies that both stale tokens receive HTTP 401 while the replacement token continues through the shared authentication boundary. - -The regression also signs malformed token-version claims and a validly signed token for a nonexistent user, proving that bearer middleware, calendar, SSE, and attachment-view transports all reject them with HTTP 401. The attachment regression deliberately requests a missing attachment: a valid session reaches tenant-scoped lookup and receives HTTP 404, while a malformed, nonexistent-user, or revoked session is rejected earlier with HTTP 401. This proves authentication ordering without requiring a fixture attachment. - -The regression is part of `npm run test:api`. Every synchronized head must rerun Server Tests, Security Scan, SAST Semgrep, Dependency Review, OSV Scanner, and Fuzz before merge.