8000 feat: 웹 읽기 캐시 재시도와 접수 실험 추가 by hmmhmmhm · Pull Request #347 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/superpowers/plans/2026-11-02-web-read-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# 웹 읽기 재시도 구현 계획

**Goal:** 실제 ChatGPT 웹에서 캐시 재시도 및 접수형 조회를 검증하고 복사 재전송 안내를 일치시킨다.

**Architecture:** 고정 가상 작업만 받는 실행번호별 Durable Object와 기존 실험 Worker를 사용한다. 운영 변경은 실험 이후 판단하며 JSON/MCP 계약을 보존한다.

- [ ] tests/scripts/web-read-lab-jobs.test.ts: 첫 지연·저장된 동일 결과·동시접수·pending/ready 링크·알람완료·24시간정리·외부실패 전파 회귀를 먼저 추가하고 resource guard를 통한 RED 확인.
- [ ] experiments/web-read-lab/jobs.ts 및 worker.ts/wrangler.toml: 고정 cold5/start5/result5 경로와 독립 DO 바인딩 구현. 가상시계 GREEN 후 기존 lab 회귀 및 CI/Coverage/리뷰 확인, 병합·실험 배포.
- [ ] BrowserSkill 소유 창1개/임시 채팅별 새 실행번호: delay5 대조 재시도, cold5 첫실패/동일URL재시도, start5 접수/실제결과링크를 직렬 비교. 캡처를 탐색 전에 시작하고 각 표본 후 UI와 서버 ID·시각을 저장한 뒤 탭 회수.
- [ ] README, src/pages/llms.ts 등 공통 발견 안내와 docs/client-guide.md, service-reference.md, web-reading-troubleshooting.md를 검색해 완성 URL 복사 재전송 문구와 실제 링크 우선 지침을 통일. 원본 이미지·freshness·공개도구10 계약 유지.
- [ ] 접수 실험이 성공하면 운영의 저장·권한·한도·원본 오류·작업수 제한 설계를 구체화한 뒤 회귀·리뷰·CI로 구현/배포. 성공을 확인하지 못하면 운영 확대 없이 제한과 후속 조건을 기록.
- [ ] PROJECT.md와 web-read-lab/2026-11-02에 결과·정확한 한계·배포 근거 저장, 세션 및 탭 정리, CI/배포 결과를 확인해 보고.
17 changes: 17 additions & 0 deletions docs/superpowers/specs/2026-11-02-web-read-retry-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 웹 읽기 재시도와 접수형 조회 검증

사용자는 2026-11-02 실제 ChatGPT 임시 채팅의 첫 실패 뒤 캐시 재시도 실험, 완성 URL 복사 재전송 안내, 문서 정합성 수정 및 효과가 확인된 접수형 조회의 운영 반영을 승인했다.

## 실험 설계

기존 독립 Worker의 가상 데이터만 사용한다. 새 실행번호별로 매번5초 지연, 최초5초 후 결과 저장, 즉시접수 후 실제 결과 링크를 비교한다. Chat 질문에 최종 주소를 제공하여 URL 출처 문제를 먼저 통제하고, 같은 주소 재시도와 실제 링크 이동을 구분한다. 현재 데이터·requestId·jobId와 서버 관측 시각을 대조한다. 모든 응답 HTML은 no-store이며 저장 결과의 재사용과 OpenAI의 사본을 구별한다. 접수는 성공/재고로 표현하지 않는다. pending 링크의 반복 횟수는 제한한다.

## 구현 경계

실험 상태는 실행번호별 Durable Object에 보관하고24시간 뒤 삭제한다. 고정5초 작업만 처리하며 외부 URL/실제 재고/자격증명을 받지 않는다. 첫 클라이언트 연결이 끊겨도 예약 알람으로 작업 완료를 보장하는지 실제 관측한다. 기존 JSON API·공개 MCP10도구·중계·원장은 변경하지 않는다.

완성 URL이 있는 복사용 문장은 `아래 주소를 웹 URL 열기 도구로 직접 열어 실제 결과를 확인해주세요: {현재 조회 조건의 정확한 공개 URL}` 형식으로 제공한다. 접근 거절과 TimeoutError를 구분하고 단순 허용 문구나 URL 일부 제공을 대체로 안내하지 않는다. 문서의 웹 전용 URL 조합 지침을 실제 링크 우선으로 맞춘다.

## 운영 반영 기준

실제 Chat 웹에서 접수 응답 및 후속 결과의 새 ID가 일치하고, 반복 표본과 실패 대조를 확보한 뒤 기존 Worker의 한도·권한·캐시를 그대로 사용하는 제한된 조회 흐름을 검토한다. 실험 성공만으로 운영의 느린 원본·캐시·중단·오류 보존을 보장하지 않는다. 확인되지 않으면 실험 기능과 문서 개선만 배포하고 이유를 기록한다. 운영 접수형 구현은 별도 회귀·리뷰·필수 CI 이후에만 배포한다.
65 changes: 65 additions & 0 deletions experiments/web-read-lab/jobs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/** 고정 가상 작업만 저장하며 운영 데이터나 임의 URL을 받지 않습니다. */
import type { Storage } from './worker.js';
const DAY = 86400000;
type Job = {
jobId: string;
status: 'pending' | 'ready';
acceptedAt: number;
readyAt: number;
completedAt?: string;
resultId?: string;
quantity?: number;
};

export async function completeJob(storage: Storage): Promise<void> {
await storage.transaction(async (tx) => {
const job = (await tx.get('job')) as Job | undefined;
if (job?.status === 'pending' && Date.now() >= job.readyAt) {
await tx.put('job', {
...job,
status: 'ready',
completedAt: new Date().toISOString(),
resultId: crypto.randomUUID(),
quantity: 7,
});
}
});
}

export async function alarmJob(storage: Storage): Promise<void> {
const job = (await storage.get('job')) as Job | undefined;
if (!job || Date.now() >= job.acceptedAt + DAY) {
await storage.deleteAll();
return;
}
await completeJob(storage);
await storage.setAlarm(Date.now() < job.readyAt ? job.readyAt : job.acceptedAt + DAY);
}

export async function jobResponse(storage: Storage, mode: string): Promise<Response> {
const now = Date.now();
let created = false;
const job = await storage.transaction(async (tx) => {
const old = (await tx.get('job')) as Job | undefined;
if (old && now < old.acceptedAt + DAY) return old;
if (mode === 'result5') return undefined;
const next: Job = {
jobId: crypto.randomUUID(),
status: 'pending',
acceptedAt: now,
readyAt: now + 5000,
};
await tx.put('job', next);
created = true;
return next;
});
if (!job) return Response.json({ status: 'not-found' }, { status: 404 });
if (created) await storage.setAlarm(job.readyAt);
if (mode === 'cold5' && job.status === 'pending') {
await new Promise((resolve) => setTimeout(resolve, Math.max(0, job.readyAt - Date.now())));
await completeJob(storage);
await storage.setAlarm(job.acceptedAt + DAY);
}
const current = (await storage.get('job')) as Job;
return Response.json({ ...current, cacheHit: !created && current.status === 'ready' });
}
20 changes: 18 additions & 2 deletions experiments/web-read-lab/worker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** 운영 API와 분리된 고정 데이터 웹 읽기 실험. 개인정보는 기록하지 않습니다. */
import { alarmJob, jobResponse } from './jobs.js';
import { renderComposer } from './composer.js';
type Event = {
run: string;
Expand Down Expand Up @@ -35,6 +36,9 @@ const CASES = [
'delay3',
'delay5',
'delay15',
'cold5',
'start5',
'result5',
'large64',
'large256',
'cache',
Expand All @@ -47,6 +51,8 @@ const RUN = /^[a-zA-Z0-9-]{1,48}$/;
export class ProbeLog {
constructor(private state: { storage: Storage }) {}
async fetch(request: Request): Promise<Response> {
const mode = new URL(request.url).pathname.slice(1);
if (['cold5', 'start5', 'result5'].includes(mode)) return jobResponse(this.state.storage, mode);
if (request.method === 'POST') {
const event = (await request.json()) as Event;
await this.state.storage.transaction(async (tx) => {
Expand All @@ -67,7 +73,8 @@ export class ProbeLog {
);
}
async alarm(): Promise<void> {
await this.state.storage.deleteAll();
if (await this.state.storage.get('job')) await alarmJob(this.state.storage);
else await this.state.storage.deleteAll();
}
}

Expand Down Expand Up @@ -155,7 +162,16 @@ export default {
`<p>가상 실험 데이터, 실제 상품 재고가 아닙니다.</p><pre>${JSON.stringify(data, null, 2)}</pre>`,
);
let status = 200;
if (isComposer) {
if (['cold5', 'start5', 'result5'].includes(test)) {
const job = env.PROBE_LOG.get(env.PROBE_LOG.idFromName(`job-${run}`));
const result = await job.fetch(new Request(`https://job/${test}`));
const state = await result.json();
const { quantity: _quantity, ...metadata } = data;
status = result.status;
body = html(
`<p>가상 작업 실험. pending은 조회 완료나 재고가 아닙니다.</p><pre>${JSON.stringify({ ...metadata, job: state }, null, 2)}</pre><p><a href="/run/${run}/result5">결과 확인</a></p><p>결과가 pending이면 잠시 후 같은 실제 링크를 한 번만 다시 확인하세요.</p>`,
);
} else if (isComposer) {
headers.set('X-Robots-Tag', 'noindex');
body = html(composer!);
} else if (test === 'index') {
Expand Down
101 changes: 101 additions & 0 deletions tests/scripts/web-read-lab-jobs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import worker, { ProbeLog, type Storage } from '../../experiments/web-read-lab/worker.js';

function fixture() {
const objects = new Map<string, ProbeLog>();
const env = {
PROBE_LOG: {
idFromName: (name: string) => name,
get(id: unknown) {
const key = String(id);
if (!objects.has(key)) {
const values = new Map<string, unknown>();
let queue = Promise.resolve();
const storage: Storage = {
transaction<T>(fn: (tx: Pick<Storage, 'get' | 'put'>) => Promise<T>) {
const result = queue.then(() => fn(storage));
queue = result.then(
() => {},
() => {},
);
return result;
},
get: async (name) => values.get(name),
put: async (name, value) => {
values.set(name, value);
},
setAlarm: vi.fn(async () => {}),
deleteAll: async () => {
values.clear();
},
};
objects.set(key, new ProbeLog({ storage }));
}
return objects.get(key)!;
},
},
};
return {
env,
get: (path: string) => worker.fetch(new Request(`https://lab.example${path}`), env),
};
}
afterEach(() => vi.useRealTimers());

describe('웹 읽기 저장 결과와 접수 실험', () => {
it('즉시 접수하고 실제 결과 링크에서 pending과 완료를 구별한다', async () => {
vi.useFakeTimers();
const { get, env } = fixture();
const first = await get('/run/staged/start5');
expect(first.status).toBe(200);
const body = await first.text();
expect(body).toContain('pending');
expect(body).not.toContain('quantity');
expect(body).toContain('href="/run/staged/result5"');
expect(await (await get('/run/staged/result5')).text()).toContain('pending');
await vi.advanceTimersByTimeAsync(5000);
await env.PROBE_LOG.get('job-staged').alarm();
const ready = await get('/run/staged/result5');
expect(await ready.text()).toContain('ready');
expect(ready.headers.get('cache-control')).toBe('no-store');
});
it('첫 조회만 느리고 재시도는 동일한 저장 결과를 즉시 반환한다', async () => {
vi.useFakeTimers();
const { get, env } = fixture();
let done = false;
const cold = get('/run/cold/cold5').then((r) => {
done = true;
return r;
});
await vi.advanceTimersByTimeAsync(4999);
expect(done).toBe(false);
await vi.advanceTimersByTimeAsync(1);
const first = await (await cold).text();
expect(first).toContain('ready');
const second = await (await get('/run/cold/cold5')).text();
expect(second.match(/"resultId": "([^"]+)"/)?.[1]).toBe(
first.match(/"resultId": "([^"]+)"/)?.[1],
);
expect(first.match(/"resultId": "([^"]+)"/)?.[1]).toBeTruthy();
expect(second).toContain('cacheHit');
await env.PROBE_LOG.get('job-cold').alarm();
expect(await (await get('/run/cold/result5')).text()).toContain('ready');
});
it('동시 접수는 한 작업을 공유하며 알람이 결과를 보존하고 만료 후 정리한다', async () => {
vi.useFakeTimers();
const { get, env } = fixture();
const [a, b] = await Promise.all([get('/run/shared/start5'), get('/run/shared/start5')]);
const id = (s: string) => s.match(/"jobId": "([^"]+)"/)?.[1];
const first = id(await a.text());
expect(first).toBeTruthy();
expect(id(await b.text())).toBe(first);
expect((await get('/run/unknown/result5')).status).toBe(404);
await vi.advanceTimersByTimeAsync(5000);
await env.PROBE_LOG.get('job-shared').alarm();
const ready = await (await get('/run/shared/result5')).text();
expect(ready).toContain('completedAt');
await vi.advanceTimersByTimeAsync(86400001);
await env.PROBE_LOG.get('job-shared').alarm();
expect((await get('/run/shared/result5')).status).toBe(404);
});
});
Loading
0