e10-ebuilder-prototype 0.5.5 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +45 -13
  2. package/dist/archive.d.ts +1 -1
  3. package/dist/archive.js +3 -0
  4. package/dist/capture.js +1 -1
  5. package/dist/common.d.ts +2 -1
  6. package/dist/common.js +24 -4
  7. package/dist/form-behavior-runtime.d.mts +1 -0
  8. package/dist/form-behavior-runtime.mjs +681 -0
  9. package/dist/form-behavior.d.ts +11 -0
  10. package/dist/form-behavior.js +169 -0
  11. package/dist/form-context.d.ts +4 -0
  12. package/dist/form-context.js +7 -5
  13. package/dist/form-generation.d.ts +24 -0
  14. package/dist/form-generation.js +367 -0
  15. package/dist/form-runtime.mjs +11 -2
  16. package/dist/host-ledger.d.ts +23 -0
  17. package/dist/host-ledger.js +183 -0
  18. package/dist/host-watch.d.ts +102 -0
  19. package/dist/host-watch.js +112 -0
  20. package/dist/html-handoff.d.ts +1 -0
  21. package/dist/html-handoff.js +30 -2
  22. package/dist/html-inspect.d.ts +2 -2
  23. package/dist/html-inspect.js +23 -51
  24. package/dist/html-interact.d.ts +36 -0
  25. package/dist/html-interact.js +216 -0
  26. package/dist/html-review-budget.d.ts +9 -0
  27. package/dist/html-review-budget.js +47 -0
  28. package/dist/html.d.ts +77 -5
  29. package/dist/html.js +155 -41
  30. package/dist/index.js +119 -44
  31. package/dist/model.d.ts +2 -1
  32. package/dist/offline-render.d.ts +11 -0
  33. package/dist/offline-render.js +71 -0
  34. package/dist/offline-store.mjs +10 -0
  35. package/dist/runtime-support.d.mts +29 -1
  36. package/dist/runtime-support.mjs +55 -5
  37. package/dist/site.js +66 -51
  38. package/dist/store.js +18 -4
  39. package/dist/templates/form-guide.md +6 -20
  40. package/dist/templates/form-task-core.md +45 -5
  41. package/dist/templates/index.html +5 -3
  42. package/dist/templates/workflow-guide.md +4 -2
  43. package/dist/vendor/environment-auth.d.ts +1 -0
  44. package/dist/vendor/environment-auth.js +4 -4
  45. package/docs/PROTOCOL.md +341 -39
  46. package/package.json +1 -1
@@ -0,0 +1,23 @@
1
+ import type { Store } from './store.js';
2
+ import type { TaskState } from './model.js';
3
+ type Worker = {
4
+ taskId: string;
5
+ token: string;
6
+ kind: 'page' | 'form';
7
+ pageId: string;
8
+ startedAt: string;
9
+ nativeName?: string;
10
+ outcome?: string;
11
+ endedAt?: string;
12
+ };
13
+ type Ledger = {
14
+ schema: 1;
15
+ workers: Worker[];
16
+ };
17
+ export declare function readHostLedger(store: Store): Promise<Ledger | undefined>;
18
+ export declare function parseHostEvents(raw: string): any[];
19
+ /** Called only under the existing coordinator lock. A missing event never means exit. */
20
+ export declare function updateHostLedger(store: Store, state: TaskState, events: any[]): Promise<Ledger>;
21
+ export declare function activeHostWorkers(store: Store): Promise<Worker[] | undefined>;
22
+ export declare function parseHostActiveTokens(value: string): string[];
23
+ export {};
@@ -0,0 +1,183 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { atomicJson, CaptureError } from './common.js';
4
+ const filename = (store) => path.join(store.meta, 'host-workers.json');
5
+ const identity = (v) => typeof v === 'string' && /^[a-zA-Z0-9_-]{1,128}$/.test(v);
6
+ const nativeName = (v) => typeof v === 'string' &&
7
+ v.length <= 256 &&
8
+ /^(?:e10-[a-f0-9-]{36}(?:-[0-9]+)?|(?:页面|表单)-[\p{L}\p{N}_-]+)$/u.test(v);
9
+ export async function readHostLedger(store) {
10
+ let value;
11
+ try {
12
+ value = JSON.parse(await fs.readFile(filename(store), 'utf8'));
13
+ }
14
+ catch (e) {
15
+ if (e.code === 'ENOENT')
16
+ return;
17
+ throw new CaptureError('HOST_LEDGER_INVALID', '后台任务登记文件损坏,不能按空列表释放名额');
18
+ }
19
+ if (value?.schema !== 1 ||
20
+ !Array.isArray(value.workers) ||
21
+ value.workers.some((w) => !identity(w.taskId) ||
22
+ !identity(w.token) ||
23
+ !['page', 'form'].includes(w.kind) ||
24
+ typeof w.pageId !== 'string' ||
25
+ !Number.isFinite(Date.parse(w.startedAt)) ||
26
+ (w.endedAt !== undefined &&
27
+ (!Number.isFinite(Date.parse(w.endedAt)) ||
28
+ !['completed', 'failed', 'cancelled'].includes(w.outcome)))) ||
29
+ new Set(value.workers.map((w) => w.taskId)).size !== value.workers.length ||
30
+ new Set(value.workers.map((w) => w.token)).size !== value.workers.length)
31
+ throw new CaptureError('HOST_LEDGER_INVALID', '后台任务登记无效,不能释放名额');
32
+ return value;
33
+ }
34
+ export function parseHostEvents(raw) {
35
+ let events;
36
+ try {
37
+ events = JSON.parse(raw);
38
+ }
39
+ catch {
40
+ throw new CaptureError('ARGUMENT_INVALID', '--host-events 需要 JSON 数组');
41
+ }
42
+ if (Array.isArray(events))
43
+ events = events.map((event) => {
44
+ if (event?.event !== 'started' || event.receipt === undefined)
45
+ return event;
46
+ // Copy the native spawn response intact. Never pair parallel responses by order.
47
+ const receipt = event.receipt;
48
+ const names = typeof receipt === 'string' ? [...receipt.matchAll(/^name: ([^\r\n]+)\r?$/gm)] : [];
49
+ const ids = typeof receipt === 'string'
50
+ ? [...receipt.matchAll(/^task_id: ([a-zA-Z0-9_-]{1,128})\r?$/gm)]
51
+ : [];
52
+ if (typeof receipt !== 'string' ||
53
+ receipt.length > 16000 ||
54
+ !receipt.startsWith('Spawned successfully.') ||
55
+ names.length !== 1 ||
56
+ !nativeName(names[0][1]) ||
57
+ ids.length !== 1)
58
+ throw new CaptureError('HOST_START_RECEIPT_INVALID', '需要含唯一 name 和 task_id 的完整原生后台启动回执;同步结果不能登记');
59
+ const renames = [
60
+ ...receipt.matchAll(/^Note: requested name "([^"]+)" was already in use \(or reserved for the team leader\)\. Renamed to "([^"]+)"\. Use this name when addressing the teammate via SendMessage\.\r?$/gm),
61
+ ];
62
+ if (renames.length > 1 ||
63
+ [...receipt.matchAll(/^Note: requested name /gm)].length !== renames.length ||
64
+ (renames.length === 1 &&
65
+ (!nativeName(renames[0][1]) ||
66
+ renames[0][2] !== names[0][1] ||
67
+ !/^-(?:[2-9]|[1-9][0-9]+)$/.test(names[0][1].slice(renames[0][1].length)) ||
68
+ !names[0][1].startsWith(renames[0][1]))))
69
+ throw new CaptureError('HOST_START_RECEIPT_INVALID', '原生重名回执不一致,拒绝登记');
70
+ const requestedName = renames[0]?.[1] || names[0][1];
71
+ const legacyToken = /^e10-([a-f0-9-]{36})$/.exec(requestedName)?.[1];
72
+ if ((legacyToken && event.token !== undefined && event.token !== legacyToken) ||
73
+ (event.taskId !== undefined && event.taskId !== ids[0][1]))
74
+ throw new CaptureError('HOST_IDENTITY_MISMATCH', '手工身份与原生启动回执不一致,拒绝登记;不要改写回执');
75
+ return {
76
+ event: 'started',
77
+ taskId: ids[0][1],
78
+ token: legacyToken || event.token,
79
+ nativeName: names[0][1],
80
+ requestedName,
81
+ };
82
+ });
83
+ if (!Array.isArray(events) ||
84
+ events.length > 100 ||
85
+ events.some((e) => !e ||
86
+ !['started', 'terminal'].includes(e.event) ||
87
+ !identity(e.taskId) ||
88
+ (e.event === 'started'
89
+ ? (e.token === undefined ? !nativeName(e.requestedName) : !identity(e.token)) ||
90
+ (e.nativeName !== undefined && !nativeName(e.nativeName)) ||
91
+ (e.requestedName !== undefined && !nativeName(e.requestedName))
92
+ : e.token !== undefined && !identity(e.token)) ||
93
+ (e.event === 'terminal' &&
94
+ (e.executionEnded !== true || !['completed', 'failed', 'cancelled'].includes(e.outcome)))))
95
+ throw new CaptureError('ARGUMENT_INVALID', '启动需要原生 receipt(兼容 taskId/token);终态需要 taskId、outcome 和 executionEnded:true 的宿主确认');
96
+ return events;
97
+ }
98
+ /** Called only under the existing coordinator lock. A missing event never means exit. */
99
+ export async function updateHostLedger(store, state, events) {
100
+ events = parseHostEvents(JSON.stringify(events));
101
+ const ledger = (await readHostLedger(store)) || { schema: 1, workers: [] };
102
+ // Read each allocation once per transaction, even for a whole batch of starts.
103
+ const allocations = events.some((e) => e.event === 'started')
104
+ ? (await Promise.all([
105
+ ...(state.pages || []).map((p) => ({ kind: 'page', id: p.id })),
106
+ ...(state.formPages || []).map((p) => ({ kind: 'form', id: p.id })),
107
+ ].map(async (item) => {
108
+ const result = await store.htmlResult(item.id, item.kind);
109
+ return result && ['running', 'succeeded'].includes(result.status)
110
+ ? [{ ...item, result }]
111
+ : [];
112
+ }))).flat()
113
+ : [];
114
+ for (const event of events) {
115
+ const old = ledger.workers.find((w) => w.taskId === event.taskId);
116
+ if (event.event === 'started' && event.requestedName) {
117
+ const matches = [];
118
+ if (old &&
119
+ (old.nativeName === event.nativeName || event.requestedName === `e10-${old.token}`))
120
+ matches.push(old);
121
+ for (const { result: r } of allocations) {
122
+ if (!matches.some((m) => m.token === r.token) &&
123
+ (event.requestedName === r.dispatchName || event.requestedName === `e10-${r.token}`))
124
+ matches.push(r);
125
+ }
126
+ if (matches.length !== 1)
127
+ throw new CaptureError('HOST_TOKEN_STALE', '原生名称必须唯一对应当前已分配任务;不要按顺序猜 token');
128
+ if (event.token !== undefined && event.token !== matches[0].token)
129
+ throw new CaptureError('HOST_IDENTITY_MISMATCH', '手工 token 与原生名称不匹配,拒绝登记');
130
+ event.token = matches[0].token;
131
+ }
132
+ if (old && event.token !== undefined && old.token !== event.token)
133
+ throw new CaptureError('HOST_IDENTITY_MISMATCH', 'taskId 与 token 不匹配,拒绝更新');
134
+ if (event.event === 'terminal') {
135
+ if (!old)
136
+ throw new CaptureError('HOST_WORKER_UNKNOWN', '未登记的 taskId 不能释放名额');
137
+ if (old.endedAt && old.outcome !== event.outcome)
138
+ throw new CaptureError('HOST_TERMINAL_CONFLICT', '重复终态的 outcome 不一致');
139
+ old.endedAt ||= new Date().toISOString();
140
+ old.outcome = event.outcome;
141
+ }
142
+ else if (!old) {
143
+ if (ledger.workers.some((w) => w.token === event.token))
144
+ throw new CaptureError('HOST_DUPLICATE_WRITER', '同一 token 已登记其他原生任务');
145
+ const allocation = allocations.find(({ result }) => result.token === event.token);
146
+ const target = allocation
147
+ ? {
148
+ taskId: event.taskId,
149
+ token: event.token,
150
+ kind: allocation.kind,
151
+ pageId: allocation.id,
152
+ startedAt: new Date().toISOString(),
153
+ ...(event.nativeName ? { nativeName: event.nativeName } : {}),
154
+ }
155
+ : undefined;
156
+ if (!target)
157
+ throw new CaptureError('HOST_TOKEN_STALE', '只能登记当前已分配的 HTML token');
158
+ if (ledger.workers.filter((w) => !w.endedAt).length >= state.settings.concurrency)
159
+ throw new CaptureError('HOST_CAPACITY_EXCEEDED', '原生任务数已达到并发上限');
160
+ ledger.workers.push(target);
161
+ }
162
+ }
163
+ await atomicJson(filename(store), ledger);
164
+ return ledger;
165
+ }
166
+ export async function activeHostWorkers(store) {
167
+ return (await readHostLedger(store))?.workers.filter((w) => !w.endedAt);
168
+ }
169
+ export function parseHostActiveTokens(value) {
170
+ let tokens;
171
+ try {
172
+ tokens = JSON.parse(value);
173
+ }
174
+ catch {
175
+ throw new CaptureError('ARGUMENT_INVALID', '--host-active-tokens 需要 JSON 字符串数组');
176
+ }
177
+ if (!Array.isArray(tokens) ||
178
+ tokens.length > 100 ||
179
+ tokens.some((token) => typeof token !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(token)) ||
180
+ new Set(tokens).size !== tokens.length)
181
+ throw new CaptureError('ARGUMENT_INVALID', '--host-active-tokens 需要不重复的有效令牌数组');
182
+ return tokens;
183
+ }
@@ -0,0 +1,102 @@
1
+ import type { Store } from './store.js';
2
+ import type { TaskState } from './model.js';
3
+ /** Local evidence only. A quiet file, ready receipt or timeout never proves native exit. */
4
+ export declare function observeHost(store: Store, state: TaskState, tokens: string[]): Promise<{
5
+ slots: ({
6
+ token: string;
7
+ htmlStatus: string;
8
+ checkReason: string;
9
+ reviewSubmitted: boolean;
10
+ kind?: undefined;
11
+ pageId?: undefined;
12
+ name?: undefined;
13
+ lastLocalActivityAt?: undefined;
14
+ localIdleMs?: undefined;
15
+ } | {
16
+ token: string;
17
+ kind: "page" | "form";
18
+ pageId: string;
19
+ name: string;
20
+ htmlStatus: "running" | "succeeded" | "failed" | "pending";
21
+ reviewSubmitted: boolean;
22
+ lastLocalActivityAt: string;
23
+ localIdleMs: number;
24
+ checkReason: string | undefined;
25
+ })[];
26
+ observationState: string;
27
+ reconcile: ({
28
+ token: string;
29
+ htmlStatus: string;
30
+ checkReason: string;
31
+ reviewSubmitted: boolean;
32
+ kind?: undefined;
33
+ pageId?: undefined;
34
+ name?: undefined;
35
+ lastLocalActivityAt?: undefined;
36
+ localIdleMs?: undefined;
37
+ } | {
38
+ token: string;
39
+ kind: "page" | "form";
40
+ pageId: string;
41
+ name: string;
42
+ htmlStatus: "running" | "succeeded" | "failed" | "pending";
43
+ reviewSubmitted: boolean;
44
+ lastLocalActivityAt: string;
45
+ localIdleMs: number;
46
+ checkReason: string | undefined;
47
+ })[];
48
+ }>;
49
+ /** One bounded background process. No auth, Chrome, queue lock or state mutations. */
50
+ export declare function watchHost(store: Store, options: {
51
+ activeTokens: string[];
52
+ afterState?: string;
53
+ watchMs?: number;
54
+ }): Promise<{
55
+ reason: string;
56
+ elapsedMs: number;
57
+ next: string;
58
+ releasesSlots: boolean;
59
+ slots: ({
60
+ token: string;
61
+ htmlStatus: string;
62
+ checkReason: string;
63
+ reviewSubmitted: boolean;
64
+ kind?: undefined;
65
+ pageId?: undefined;
66
+ name?: undefined;
67
+ lastLocalActivityAt?: undefined;
68
+ localIdleMs?: undefined;
69
+ } | {
70
+ token: string;
71
+ kind: "page" | "form";
72
+ pageId: string;
73
+ name: string;
74
+ htmlStatus: "running" | "succeeded" | "failed" | "pending";
75
+ reviewSubmitted: boolean;
76
+ lastLocalActivityAt: string;
77
+ localIdleMs: number;
78
+ checkReason: string | undefined;
79
+ })[];
80
+ observationState: string;
81
+ reconcile: ({
82
+ token: string;
83
+ htmlStatus: string;
84
+ checkReason: string;
85
+ reviewSubmitted: boolean;
86
+ kind?: undefined;
87
+ pageId?: undefined;
88
+ name?: undefined;
89
+ lastLocalActivityAt?: undefined;
90
+ localIdleMs?: undefined;
91
+ } | {
92
+ token: string;
93
+ kind: "page" | "form";
94
+ pageId: string;
95
+ name: string;
96
+ htmlStatus: "running" | "succeeded" | "failed" | "pending";
97
+ reviewSubmitted: boolean;
98
+ lastLocalActivityAt: string;
99
+ localIdleMs: number;
100
+ checkReason: string | undefined;
101
+ })[];
102
+ }>;
@@ -0,0 +1,112 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+ import { CaptureError, digest, mapLimit } from './common.js';
5
+ import { navigationKey } from './menus.js';
6
+ /** Local evidence only. A quiet file, ready receipt or timeout never proves native exit. */
7
+ export async function observeHost(store, state, tokens) {
8
+ const targets = [
9
+ ...(state.pages || []).map((p) => ({ ...p, kind: 'page' })),
10
+ ...(state.menuRequired ? state.formPages || [] : []).map((p) => ({
11
+ ...p,
12
+ kind: 'form',
13
+ })),
14
+ ];
15
+ const receipts = await mapLimit(targets, 8, async (p) => ({
16
+ item: p,
17
+ receipt: await store.htmlResult(p.id, p.kind),
18
+ }));
19
+ const byToken = new Map(receipts.filter((p) => p.receipt).map((p) => [p.receipt.token, p]));
20
+ const now = Date.now();
21
+ const slots = await Promise.all(tokens.map(async (token) => {
22
+ const matched = byToken.get(token);
23
+ if (!matched?.receipt)
24
+ return {
25
+ token,
26
+ htmlStatus: 'unknown',
27
+ checkReason: 'unknown-reservation',
28
+ reviewSubmitted: false,
29
+ };
30
+ const { receipt: r, item } = matched;
31
+ const dir = path.join(store.meta, 'html-drafts', r.token);
32
+ const draft = path.join(dir, `${item.kind === 'form' ? navigationKey(r.id) : r.id}.html`);
33
+ const reviewPath = path.join(dir, 'review', 'host-review.json');
34
+ const [draftStat, reviewStat, review] = await Promise.all([
35
+ fs.stat(draft).catch(() => null),
36
+ fs.stat(reviewPath).catch(() => null),
37
+ fs
38
+ .readFile(reviewPath, 'utf8')
39
+ .then(JSON.parse)
40
+ .catch(() => null),
41
+ ]);
42
+ const reviewSubmitted = review?.schema === 1 &&
43
+ review.token === token &&
44
+ review.pageId === r.id &&
45
+ review.kind === item.kind;
46
+ const lastLocalActivityMs = Math.max(Date.parse(r.startedAt) || 0, Date.parse(r.finishedAt || '') || 0, draftStat?.mtimeMs || 0, reviewStat?.mtimeMs || 0);
47
+ const localIdleMs = Math.max(0, Math.floor(now - lastLocalActivityMs));
48
+ return {
49
+ token,
50
+ kind: item.kind,
51
+ pageId: r.id,
52
+ name: item.name,
53
+ htmlStatus: r.status,
54
+ reviewSubmitted,
55
+ lastLocalActivityAt: new Date(lastLocalActivityMs).toISOString(),
56
+ localIdleMs,
57
+ checkReason: r.status !== 'running'
58
+ ? 'html-terminal-native-unconfirmed'
59
+ : reviewSubmitted
60
+ ? 'review-submitted-native-unconfirmed'
61
+ : localIdleMs >= 300_000
62
+ ? 'no-local-progress-five-minutes'
63
+ : localIdleMs >= 180_000
64
+ ? 'no-local-progress-three-minutes'
65
+ : undefined,
66
+ };
67
+ }));
68
+ // Draft edits alone do not wake the model; handoffs, terminal receipts and stalled
69
+ // progress do. Sort tokens so changes in coordinator ordering are not new events.
70
+ const observationState = digest(JSON.stringify([...slots]
71
+ .sort((a, b) => a.token.localeCompare(b.token))
72
+ .map(({ token, htmlStatus, reviewSubmitted, checkReason }) => ({
73
+ token,
74
+ htmlStatus,
75
+ reviewSubmitted,
76
+ checkReason,
77
+ }))));
78
+ return { slots, observationState, reconcile: slots.filter((slot) => slot.checkReason) };
79
+ }
80
+ /** One bounded background process. No auth, Chrome, queue lock or state mutations. */
81
+ export async function watchHost(store, options) {
82
+ const watchMs = options.watchMs ?? 60_000;
83
+ if (!Number.isInteger(watchMs) || watchMs < 1 || watchMs > 60_000)
84
+ throw new CaptureError('ARGUMENT_INVALID', '--watch-ms 范围 1..60000');
85
+ if (options.afterState !== undefined && !/^[a-f0-9]{64}$/.test(options.afterState))
86
+ throw new CaptureError('ARGUMENT_INVALID', '--after-state 需要 observationState 摘要');
87
+ const started = performance.now();
88
+ let baseline = options.afterState;
89
+ for (;;) {
90
+ const observation = await observeHost(store, await store.load(), options.activeTokens);
91
+ const elapsedMs = Math.round(performance.now() - started);
92
+ const reason = !options.activeTokens.length
93
+ ? 'no-active-workers'
94
+ : baseline !== undefined && observation.observationState !== baseline
95
+ ? 'local-state-changed'
96
+ : baseline === undefined && observation.reconcile.length
97
+ ? 'native-check-required'
98
+ : elapsedMs >= watchMs
99
+ ? 'native-status-deadline'
100
+ : undefined;
101
+ if (reason)
102
+ return {
103
+ ...observation,
104
+ reason,
105
+ elapsedMs,
106
+ next: options.activeTokens.length ? 'reconcile-native-host' : 'html next',
107
+ releasesSlots: false,
108
+ };
109
+ baseline = observation.observationState;
110
+ await delay(Math.min(1000, Math.max(1, watchMs - elapsedMs)));
111
+ }
112
+ }
@@ -7,6 +7,7 @@ export declare function readyHtml(store: Store, state: TaskState, pageId: string
7
7
  pageId: string;
8
8
  receiptPath: string;
9
9
  next: string;
10
+ instruction: string;
10
11
  frozen: boolean;
11
12
  }>;
12
13
  /** Called only by the coordinator under the queue lock. Each acceptance is durable. */
@@ -29,7 +29,25 @@ async function evidence(store, state, pageId, token, kind) {
29
29
  }
30
30
  inspections.push({ width, sha256: await fileDigest(filename) });
31
31
  }
32
- return { directory, sourceSha256, inspections };
32
+ const interactionPath = path.join(directory, 'interaction.json');
33
+ const interaction = await fs
34
+ .readFile(interactionPath, 'utf8')
35
+ .then(JSON.parse)
36
+ .catch((error) => {
37
+ if (error.code === 'ENOENT')
38
+ return undefined;
39
+ throw new CaptureError('HTML_INTERACTION_REQUIRED', '交互回执损坏,需要重跑');
40
+ });
41
+ if (interaction && (!interaction.ok || interaction.sourceSha256 !== sourceSha256))
42
+ throw new CaptureError('HTML_INTERACTION_REQUIRED', '已有交互检查失败或草稿已变化,需要对当前草稿重跑');
43
+ return {
44
+ directory,
45
+ sourceSha256,
46
+ inspections,
47
+ interaction: interaction
48
+ ? { sha256: await fileDigest(interactionPath), steps: interaction.results.length }
49
+ : undefined,
50
+ };
33
51
  }
34
52
  /** The worker attests its review; this is not an independent visual quality score. */
35
53
  export async function readyHtml(store, state, pageId, token, kind, summary) {
@@ -44,10 +62,19 @@ export async function readyHtml(store, state, pageId, token, kind, summary) {
44
62
  token,
45
63
  sourceSha256: proof.sourceSha256,
46
64
  inspections: proof.inspections,
65
+ interaction: proof.interaction,
47
66
  summary: summary.trim(),
48
67
  reviewedAt: new Date().toISOString(),
49
68
  });
50
- return { ok: true, kind, pageId, receiptPath, next: 'notify-coordinator', frozen: true };
69
+ return {
70
+ ok: true,
71
+ kind,
72
+ pageId,
73
+ receiptPath,
74
+ next: 'finish-worker',
75
+ instruction: '回执已落盘;只作简短最终回复并结束,不再调用工具(包括 SendMessage、TaskList、TaskUpdate)或等待其它任务。协调者等待原生执行终态。',
76
+ frozen: true,
77
+ };
51
78
  }
52
79
  /** Called only by the coordinator under the queue lock. Each acceptance is durable. */
53
80
  export async function acceptReadyHtml(store, state) {
@@ -71,6 +98,7 @@ export async function acceptReadyHtml(store, state) {
71
98
  receipt.pageId !== result.id ||
72
99
  receipt.kind !== kind ||
73
100
  receipt.sourceSha256 !== proof.sourceSha256 ||
101
+ JSON.stringify(receipt.interaction) !== JSON.stringify(proof.interaction) ||
74
102
  JSON.stringify(receipt.inspections) !== JSON.stringify(proof.inspections) ||
75
103
  typeof receipt.summary !== 'string' ||
76
104
  !receipt.summary.trim() ||
@@ -2,6 +2,6 @@ import { launchChrome } from './capture.js';
2
2
  import type { Store } from './store.js';
3
3
  import type { TaskState } from './model.js';
4
4
  /** One host call and at most one owned Chrome for the required form viewports. */
5
- export declare function inspectHtmlViewports(store: Store, state: TaskState, pageId: string, token: string, kind?: 'page' | 'form', width?: number): Promise<any>;
5
+ export declare function inspectHtmlViewports(store: Store, state: TaskState, pageId: string, token: string, kind?: 'page' | 'form', width?: number, repairReason?: string): Promise<any>;
6
6
  /** Deterministic local rendering, never a visual similarity assertion or model call. */
7
- export declare function inspectHtml(store: Store, state: TaskState, pageId: string, token: string, kind?: 'page' | 'form', width?: number, sharedChrome?: () => Promise<Awaited<ReturnType<typeof launchChrome>>>): Promise<any>;
7
+ export declare function inspectHtml(store: Store, state: TaskState, pageId: string, token: string, kind?: 'page' | 'form', width?: number, sharedChrome?: () => Promise<Awaited<ReturnType<typeof launchChrome>>>, repairReason?: string): Promise<any>;
@@ -1,20 +1,22 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { pathToFileURL } from 'node:url';
3
+ import { loadOfflinePage } from './offline-render.js';
4
4
  import { bounded, CaptureError, digest, fileDigest, atomicJson } from './common.js';
5
5
  import { launchChrome } from './capture.js';
6
6
  import { prepareHtml } from './html.js';
7
+ import { reviewRevision, validateRepairReason } from './html-review-budget.js';
7
8
  /** One host call and at most one owned Chrome for the required form viewports. */
8
- export async function inspectHtmlViewports(store, state, pageId, token, kind = 'page', width) {
9
+ export async function inspectHtmlViewports(store, state, pageId, token, kind = 'page', width, repairReason) {
10
+ validateRepairReason(repairReason);
9
11
  if (kind === 'page' || width !== undefined)
10
- return inspectHtml(store, state, pageId, token, kind, width);
12
+ return inspectHtml(store, state, pageId, token, kind, width, undefined, repairReason);
11
13
  let chrome;
12
14
  const started = Date.now();
13
15
  try {
14
16
  const getChrome = async () => (chrome ??= await launchChrome());
15
17
  const inspections = [];
16
18
  for (const w of [1440, 390])
17
- inspections.push(await inspectHtml(store, state, pageId, token, kind, w, getChrome));
19
+ inspections.push(await inspectHtml(store, state, pageId, token, kind, w, getChrome, repairReason));
18
20
  if (new Set(inspections.map((r) => r.sourceSha256)).size !== 1)
19
21
  throw new CaptureError('HTML_REVIEW_STALE', '两种视口校对期间草稿发生变化,请重新校对');
20
22
  return {
@@ -30,7 +32,8 @@ export async function inspectHtmlViewports(store, state, pageId, token, kind = '
30
32
  }
31
33
  }
32
34
  /** Deterministic local rendering, never a visual similarity assertion or model call. */
33
- export async function inspectHtml(store, state, pageId, token, kind = 'page', width, sharedChrome) {
35
+ export async function inspectHtml(store, state, pageId, token, kind = 'page', width, sharedChrome, repairReason) {
36
+ validateRepairReason(repairReason);
34
37
  const prepared = await prepareHtml(store, state, pageId, token, kind);
35
38
  const viewport = {
36
39
  width: width ?? (kind === 'page' ? prepared.png.width : 1440),
@@ -42,7 +45,7 @@ export async function inspectHtml(store, state, pageId, token, kind = 'page', wi
42
45
  await fs.mkdir(directory, { recursive: true });
43
46
  const receiptPath = path.join(directory, `inspection-${viewport.width}.json`);
44
47
  const sourceSha256 = digest(prepared.bytes);
45
- const key = digest(JSON.stringify({ version: 3, sourceSha256, input: prepared.png.sha256, viewport }));
48
+ const key = digest(JSON.stringify({ version: 4, sourceSha256, input: prepared.png.sha256, viewport }));
46
49
  const old = await fs
47
50
  .readFile(receiptPath, 'utf8')
48
51
  .then(JSON.parse)
@@ -53,14 +56,15 @@ export async function inspectHtml(store, state, pageId, token, kind = 'page', wi
53
56
  if (valid.length && valid.every(Boolean))
54
57
  return { ...old, reused: true, receiptPath };
55
58
  }
59
+ const reviewBudget = await reviewRevision(directory, sourceSha256, repairReason);
56
60
  const started = Date.now();
57
61
  const errors = new Set();
58
62
  const runtimeErrors = [];
59
63
  const runtimeErrorDetails = [];
60
64
  const screenshots = [];
65
+ let fixedBehavior;
61
66
  const previewPath = path.join(directory, 'preview.html');
62
67
  await fs.writeFile(previewPath, prepared.bytes, { mode: 0o600 });
63
- const previewUrl = pathToFileURL(previewPath).href;
64
68
  const sourceLines = prepared.bytes.toString('utf8').split(/\r?\n/);
65
69
  const chrome = await (sharedChrome ? sharedChrome() : launchChrome());
66
70
  try {
@@ -70,52 +74,18 @@ export async function inspectHtml(store, state, pageId, token, kind = 'page', wi
70
74
  serviceWorkers: 'block',
71
75
  });
72
76
  try {
73
- await context.route('**/*', async (route) => {
74
- if (route.request().url() === previewUrl || /^(data:|about:)/.test(route.request().url()))
75
- await route.continue();
76
- else {
77
- errors.add('页面尝试访问外部或旁路资源');
78
- await route.abort();
79
- }
80
- });
81
77
  const page = await context.newPage();
82
78
  try {
83
- // Capture browser source locations, including parse errors without a JS stack.
84
- await page.addInitScript(() => {
85
- ;
86
- window.__E10_INSPECTION_ERRORS__ = [];
87
- addEventListener('error', (event) => {
88
- const errors = window.__E10_INSPECTION_ERRORS__;
89
- if (errors.length < 10 && event.message)
90
- errors.push({
91
- message: event.message.slice(0, 500),
92
- url: event.filename,
93
- line: event.lineno,
94
- column: event.colno,
95
- });
96
- });
97
- });
98
- page.on('pageerror', (error) => {
99
- errors.add('页面存在 JavaScript 运行错误');
100
- if (runtimeErrors.length < 10)
101
- runtimeErrors.push(`${error.name}: ${error.message}`.slice(0, 500));
102
- });
103
- page.on('websocket', () => errors.add('页面尝试建立网络连接'));
104
79
  await bounded((async () => {
105
- await page.goto(previewUrl, { waitUntil: 'load', timeout: 10000 });
106
- if (kind === 'form') {
107
- try {
108
- await page.waitForFunction(() => window.__E10_FORM_READY__ === true ||
109
- window.__E10_INSPECTION_ERRORS__?.length > 0, undefined, { timeout: 10000 });
110
- }
111
- catch (error) {
112
- if (!(error instanceof Error) || error.name !== 'TimeoutError')
113
- throw error;
114
- }
115
- if (!(await page.evaluate(() => window.__E10_FORM_READY__ === true)))
116
- errors.add('表单未完成初始化,检查脚本错误及 __E10_FORM_READY__ 设置');
117
- }
118
- await page.evaluate(() => document.fonts.ready);
80
+ const rendered = await loadOfflinePage(page, previewPath, kind === 'form');
81
+ for (const error of rendered.errors)
82
+ errors.add(error);
83
+ runtimeErrors.push(...rendered.runtimeErrors);
84
+ if (kind === 'form')
85
+ fixedBehavior = await page.evaluate(() => ({
86
+ initialized: window.__E10_FORM_BEHAVIOR__?.initialized === true,
87
+ limitations: (window.__E10_FORM_BEHAVIOR__?.limitations || []).slice(0, 40),
88
+ }));
119
89
  const layout = await page.evaluate(() => ({
120
90
  height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight),
121
91
  width: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth),
@@ -149,7 +119,7 @@ export async function inspectHtml(store, state, pageId, token, kind = 'page', wi
149
119
  }
150
120
  const browserErrors = await page.evaluate(() => window.__E10_INSPECTION_ERRORS__ || []);
151
121
  for (const error of browserErrors.slice(0, 10)) {
152
- if (error.url !== previewUrl || !Number.isInteger(error.line) || error.line < 1)
122
+ if (error.url !== rendered.url || !Number.isInteger(error.line) || error.line < 1)
153
123
  continue;
154
124
  runtimeErrorDetails.push({
155
125
  message: String(error.message).slice(0, 500),
@@ -185,9 +155,11 @@ export async function inspectHtml(store, state, pageId, token, kind = 'page', wi
185
155
  errors: [...errors],
186
156
  runtimeErrors,
187
157
  runtimeErrorDetails,
158
+ fixedBehavior,
188
159
  elapsedMs: Date.now() - started,
189
160
  reviewedAt: new Date().toISOString(),
190
161
  visual: 'host-review-required',
162
+ reviewBudget,
191
163
  };
192
164
  await atomicJson(receiptPath, receipt);
193
165
  return { ...receipt, reused: false, receiptPath };