E2E 결함 수정: Agent 절대 번들 URL, HAL 작업 재사용(실패 숨김 방지), 도메인 탐침 재개 키, 작업 엔진 동시 실행(부모/자식 교착 해소), 외부 Agent 자리표시자, JobError 4xx, 포털 HAL 이벤트·health 수신, link-portal 공유 비밀

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 13:13:39 +00:00
co-authored by Claude Fable 5.1
parent 7662d06009
commit 98f4182bbb
8 changed files with 991 additions and 853 deletions
+9 -5
View File
@@ -83,18 +83,22 @@ export function createDomainsService(deps) {
async function probeTarget(d) { if (d.site_id) { const s = store.get('SELECT server_id, deploy_mode, external_agent_id FROM sites WHERE id=?', d.site_id); if (s && s.deploy_mode === 'managed' && s.server_id) return { target: s.server_id, external: false, agent: null }; const a = s && s.deploy_mode === 'external' ? store.get('SELECT hal_agent_id FROM external_agents WHERE site_id=? AND revoked_at IS NULL AND status=\'connected\'', d.site_id) : null; const srv = (await hal().listServers({ role: 'web' }))[0]; return { target: srv ? srv.id : null, external: s && s.deploy_mode === 'external', agent: a ? a.hal_agent_id : null }; } const srv = (await hal().listServers({ role: 'web' }))[0]; return { target: srv ? srv.id : null, external: false, agent: null }; }
/** 단계에 묶인 HAL 작업 보장(portal/sites/service.mjs 의 halJobFor 와 같은 규칙): 재진입 때 같은 작업, 실패 뒤 재시도 때만 새 작업 */
async function halJobFor(ctx, job, stepId, factory) {
const st = ctx.steps().find((x) => x.id === stepId); const prev = st && st.result && st.result.halJobId ? st.result.halJobId : null;
let st = ctx.steps().find((x) => x.id === stepId); if (!st) { st = { id: stepId, title: stepId, state: 'pending' }; ctx.steps().push(st); } // 단계 밖 탐침도 단계 결과에 HAL 작업 id 를 보관
const prev = st && st.result && st.result.halJobId ? st.result.halJobId : null;
const fresh = (job.steps || []).some((x) => x.id === stepId && x.state === 'failed'); // 이 단계가 실패한 뒤의 재시도면 새 HAL 작업
if (prev && !fresh) { let hj; try { hj = await hal().getJob(prev); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL 연결 대기(정보 확인 지연)'); throw e; } if (!['failed', 'cancelled'].includes(hj.status)) return prev; }
const r = await factory(`portal:${job.id}:${stepId}${prev ? ':r' + job.attempt : ''}`); if (st) { st.result = { ...(st.result || {}), halJobId: r.halJobId }; ctx.flush(); } return r.halJobId;
if (prev && !fresh) { let hj; try { hj = await hal().getJob(prev); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL 연결 대기(정보 확인 지연)'); throw e; } return prev; } // [E2E 결함 수정] 실패·취소된 HAL 작업도 돌려줘 awaitHal 이 실패를 드러낸다(새 작업은 단계 실패 뒤 재시도 때만)
const seq = (st && st.result && st.result.seq) || 0;
const r = await factory(`portal:${job.id}:${stepId}${seq ? ':s' + seq : ''}${prev ? ':r' + job.attempt : ''}`); if (st) { st.result = { ...(st.result || {}), halJobId: r.halJobId }; ctx.flush(); } return r.halJobId;
}
/** [E2E 결함 수정] 외부 대기(사용자 DNS 변경) 직전에 단계에 적어 둔 탐침 작업 id 를 지워, 재개 때 새 탐침을 만들게 한다(옛 결과 재사용 금지). 단계가 없으면 만들어 둔다 */
function probeReset(ctx, stepId) { const st = ctx.steps().find((x) => x.id === stepId); if (st && st.result) { st.result = { ...st.result, halJobId: null, seq: (st.result.seq || 0) + 1 }; ctx.flush(); } } // seq 로 멱등 키를 바꿔 HAL 이 옛 탐침을 돌려주지 않게 한다
async function awaitHal(ctx, halJobId) { let hj; try { hj = await hal().getJob(halJobId); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL 연결 대기(정보 확인 지연)'); throw e; } if (hj.status === 'done') return hj; if (['failed', 'cancelled'].includes(hj.status)) throw new JobError(hj.errorCode || 'hal_job_failed', hj.error || '서버 작업 실패'); ctx.retryLater(5000, '서버 작업 진행 중'); }
jobs.register('domain.connect', { title: '도메인 연결 확인', steps: CONNECT_STEPS, maxAttempts: 3, retryable: (e) => e && e.code === 'service_unreachable',
async run(job, ctx) {
const d0 = get(job.subject.domainId); if (!d0) throw new JobError('domain_missing', '도메인이 없습니다'); const pt = await probeTarget(d0); if (!pt.target) throw new JobError('no_server', '확인에 쓸 서버가 없습니다', { retryable: true });
// 1 소유 확인 — TXT 값이 보일 때까지 외부 작업 대기(사용자가 DNS 를 넣는다)
const d1 = get(d0.id);
if (d1.ownership_state !== 'verified') { const r = await hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: `portal:${job.id}:own:${job.attempt}:${Date.now() / 30000 | 0}`, payload: { name: `_ariweb-verify.${d1.name}`, expectTxt: { name: `_ariweb-verify.${d1.name}`, value: `ariweb-verify=${d1.ownership_token}` } } }); const hj = await awaitHal(ctx, r.halJobId); const ok = !!(hj.result && hj.result.txtOk); record(d1.id, 'ownership', ok, hj.result); if (!ok) { save(d1.id, { last_checked_at: nowIso(), check_json: { stage: 'ownership', detail: hj.result && hj.result.txt ? `TXT 값이 다릅니다(지금 값: ${String(hj.result.txt).slice(0, 60)})` : 'TXT 레코드가 아직 보이지 않습니다' } }); ctx.waitExternal(hj.result && hj.result.txt ? `소유 확인 TXT 값이 다릅니다 — 안내된 값으로 수정한 뒤 "다시 확인"` : `소유 확인 TXT 레코드가 아직 반영되지 않았습니다 — DNS 에 넣은 뒤 "다시 확인"`); } save(d1.id, { ownership_state: 'verified', last_checked_at: nowIso() }); }
if (d1.ownership_state !== 'verified') { const hid = await halJobFor(ctx, job, 'ownership', (key) => hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: key, payload: { name: `_ariweb-verify.${d1.name}`, expectTxt: { name: `_ariweb-verify.${d1.name}`, value: `ariweb-verify=${d1.ownership_token}` } } })); const hj = await awaitHal(ctx, hid); const ok = !!(hj.result && hj.result.txtOk); record(d1.id, 'ownership', ok, hj.result); if (!ok) { save(d1.id, { last_checked_at: nowIso(), check_json: { stage: 'ownership', detail: hj.result && hj.result.txt ? `TXT 값이 다릅니다(지금 값: ${String(hj.result.txt).slice(0, 60)})` : 'TXT 레코드가 아직 보이지 않습니다' } }); probeReset(ctx, 'ownership'); ctx.waitExternal(hj.result && hj.result.txt ? `소유 확인 TXT 값이 다릅니다 — 안내된 값으로 수정한 뒤 "다시 확인"` : `소유 확인 TXT 레코드가 아직 반영되지 않았습니다 — DNS 에 넣은 뒤 "다시 확인"`); } save(d1.id, { ownership_state: 'verified', last_checked_at: nowIso() }); }
await ctx.step('ownership', () => ({ verified: true }));
// 2 DNS 반영(웹 용도만). 미반영(레코드 없음) 과 잘못된 값(다른 IP) 을 구분
const d2 = get(d0.id);
@@ -102,7 +106,7 @@ export function createDomainsService(deps) {
const recs = await requiredRecords(d2); const a = recs.find((x) => x.type === 'A');
if (pt.external) { save(d2.id, { dns_state: 'customer', dns_detail_json: toJson({ note: '외부 배포: DNS 값은 고객이 관리하며 포털이 검증하지 않습니다' }) }); }
else if (!a.value) { save(d2.id, { dns_state: 'pending', check_json: { stage: 'dns', detail: '관리형 배포를 시작해 서버가 정해진 뒤 A 레코드 값을 안내합니다' } }); ctx.waitExternal('사이트를 먼저 관리형으로 배포하면 A 레코드 값을 안내합니다'); }
else { const r = await hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: `portal:${job.id}:dns:${job.attempt}:${Date.now() / 30000 | 0}`, payload: { name: d2.name, expectA: [a.value] } }); const hj = await awaitHal(ctx, r.halJobId); const got = (hj.result && hj.result.a) || []; const state = !got.length ? 'pending' : got.includes(a.value) ? 'ok' : 'wrong'; record(d2.id, 'dns', state === 'ok', { expected: a.value, got }); save(d2.id, { dns_state: state, dns_detail_json: toJson({ expected: a.value, got, resolvedAt: hj.result && hj.result.resolvedAt }), last_checked_at: nowIso() }); if (state !== 'ok') ctx.waitExternal(state === 'wrong' ? `A 레코드 값이 다릅니다 (지금 ${got.join(', ')} / 필요 ${a.value}) — 수정 뒤 "다시 확인"` : `A 레코드가 아직 반영되지 않았습니다 (필요 ${a.value}) — 반영 뒤 "다시 확인"`); }
else { const hid = await halJobFor(ctx, job, 'dns', (key) => hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: key, payload: { name: d2.name, expectA: [a.value] } })); const hj = await awaitHal(ctx, hid); const got = (hj.result && hj.result.a) || []; const state = !got.length ? 'pending' : got.includes(a.value) ? 'ok' : 'wrong'; record(d2.id, 'dns', state === 'ok', { expected: a.value, got }); save(d2.id, { dns_state: state, dns_detail_json: toJson({ expected: a.value, got, resolvedAt: hj.result && hj.result.resolvedAt }), last_checked_at: nowIso() }); if (state !== 'ok') { probeReset(ctx, 'dns'); ctx.waitExternal(state === 'wrong' ? `A 레코드 값이 다릅니다 (지금 ${got.join(', ')} / 필요 ${a.value}) — 수정 뒤 "다시 확인"` : `A 레코드가 아직 반영되지 않았습니다 (필요 ${a.value}) — 반영 뒤 "다시 확인"`); } }
} else save(d2.id, { dns_state: 'n/a' });
await ctx.step('dns', () => ({ state: get(d0.id).dns_state }));
// 3 인증서(관리형 웹 도메인만). 외부 배포는 고객 관리