e10-ebuilder-prototype 0.5.4 → 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 (49) hide show
  1. package/README.md +56 -14
  2. package/dist/archive.d.ts +1 -1
  3. package/dist/archive.js +3 -0
  4. package/dist/capture.js +27 -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-guidance.d.ts +3 -0
  16. package/dist/form-guidance.js +28 -0
  17. package/dist/form-runtime.mjs +11 -2
  18. package/dist/host-ledger.d.ts +23 -0
  19. package/dist/host-ledger.js +183 -0
  20. package/dist/host-watch.d.ts +102 -0
  21. package/dist/host-watch.js +112 -0
  22. package/dist/html-handoff.d.ts +1 -0
  23. package/dist/html-handoff.js +30 -2
  24. package/dist/html-inspect.d.ts +4 -1
  25. package/dist/html-inspect.js +48 -50
  26. package/dist/html-interact.d.ts +36 -0
  27. package/dist/html-interact.js +216 -0
  28. package/dist/html-review-budget.d.ts +9 -0
  29. package/dist/html-review-budget.js +47 -0
  30. package/dist/html.d.ts +86 -2
  31. package/dist/html.js +185 -26
  32. package/dist/index.js +166 -44
  33. package/dist/model.d.ts +2 -1
  34. package/dist/offline-render.d.ts +11 -0
  35. package/dist/offline-render.js +71 -0
  36. package/dist/offline-store.mjs +10 -0
  37. package/dist/runtime-support.d.mts +34 -0
  38. package/dist/runtime-support.mjs +67 -0
  39. package/dist/site.js +66 -51
  40. package/dist/store.d.ts +1 -0
  41. package/dist/store.js +23 -6
  42. package/dist/templates/form-guide.md +6 -20
  43. package/dist/templates/form-task-core.md +82 -0
  44. package/dist/templates/index.html +5 -3
  45. package/dist/templates/workflow-guide.md +4 -2
  46. package/dist/vendor/environment-auth.d.ts +1 -0
  47. package/dist/vendor/environment-auth.js +4 -4
  48. package/docs/PROTOCOL.md +378 -23
  49. package/package.json +1 -1
@@ -38,7 +38,10 @@ function installFormStore(scope, objectIds, createStorage) {
38
38
  const request = (targetScope, operation, value) => {
39
39
  if (!allowed.has(targetScope))
40
40
  return Promise.reject(new Error('表单不在当前应用的已发布范围'));
41
- if (!valid(value) || JSON.stringify(value).length > 2_000_000)
41
+ if (!(operation === 'compareSave'
42
+ ? valid(value?.expected) && valid(value?.next)
43
+ : valid(value)) ||
44
+ JSON.stringify(value).length > 2_000_000)
42
45
  return Promise.reject(new Error('本地数据格式无效或超过容量'));
43
46
  if (parent === window)
44
47
  return Promise.resolve(local(targetScope, operation, value));
@@ -63,7 +66,12 @@ function installFormStore(scope, objectIds, createStorage) {
63
66
  };
64
67
  const timer = setTimeout(() => {
65
68
  finish();
66
- resolve(local(targetScope, operation, value));
69
+ // A timed-out write may already have committed in the shell. Never replay
70
+ // a compare/save locally and report a different outcome as success.
71
+ if (operation === 'compareSave')
72
+ reject(new Error('FORM_COMMIT_UNKNOWN: 主框架未确认保存,请重新加载核对'));
73
+ else
74
+ resolve(local(targetScope, operation, value));
67
75
  }, 1000);
68
76
  addEventListener('message', receive);
69
77
  parent.postMessage({ type: 'e10-form-store', requestId, operation, scope: targetScope, value }, '*');
@@ -114,6 +122,7 @@ function installFormStore(scope, objectIds, createStorage) {
114
122
  const objectStore = (targetScope) => Object.freeze({
115
123
  load: (initial) => request(targetScope, 'load', initial),
116
124
  save: (state) => request(targetScope, 'save', state),
125
+ compareSave: (expected, next) => request(targetScope, 'compareSave', { expected, next }),
117
126
  reset: (initial) => request(targetScope, 'reset', initial),
118
127
  });
119
128
  Object.defineProperty(window, 'E10FormStore', {
@@ -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() ||
@@ -1,4 +1,7 @@
1
+ import { launchChrome } from './capture.js';
1
2
  import type { Store } from './store.js';
2
3
  import type { TaskState } from './model.js';
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, repairReason?: string): Promise<any>;
3
6
  /** Deterministic local rendering, never a visual similarity assertion or model call. */
4
- export declare function inspectHtml(store: Store, state: TaskState, pageId: string, token: string, kind?: 'page' | 'form', width?: number): 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>;