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.
- package/README.md +56 -14
- package/dist/archive.d.ts +1 -1
- package/dist/archive.js +3 -0
- package/dist/capture.js +27 -1
- package/dist/common.d.ts +2 -1
- package/dist/common.js +24 -4
- package/dist/form-behavior-runtime.d.mts +1 -0
- package/dist/form-behavior-runtime.mjs +681 -0
- package/dist/form-behavior.d.ts +11 -0
- package/dist/form-behavior.js +169 -0
- package/dist/form-context.d.ts +4 -0
- package/dist/form-context.js +7 -5
- package/dist/form-generation.d.ts +24 -0
- package/dist/form-generation.js +367 -0
- package/dist/form-guidance.d.ts +3 -0
- package/dist/form-guidance.js +28 -0
- package/dist/form-runtime.mjs +11 -2
- package/dist/host-ledger.d.ts +23 -0
- package/dist/host-ledger.js +183 -0
- package/dist/host-watch.d.ts +102 -0
- package/dist/host-watch.js +112 -0
- package/dist/html-handoff.d.ts +1 -0
- package/dist/html-handoff.js +30 -2
- package/dist/html-inspect.d.ts +4 -1
- package/dist/html-inspect.js +48 -50
- package/dist/html-interact.d.ts +36 -0
- package/dist/html-interact.js +216 -0
- package/dist/html-review-budget.d.ts +9 -0
- package/dist/html-review-budget.js +47 -0
- package/dist/html.d.ts +86 -2
- package/dist/html.js +185 -26
- package/dist/index.js +166 -44
- package/dist/model.d.ts +2 -1
- package/dist/offline-render.d.ts +11 -0
- package/dist/offline-render.js +71 -0
- package/dist/offline-store.mjs +10 -0
- package/dist/runtime-support.d.mts +34 -0
- package/dist/runtime-support.mjs +67 -0
- package/dist/site.js +66 -51
- package/dist/store.d.ts +1 -0
- package/dist/store.js +23 -6
- package/dist/templates/form-guide.md +6 -20
- package/dist/templates/form-task-core.md +82 -0
- package/dist/templates/index.html +5 -3
- package/dist/templates/workflow-guide.md +4 -2
- package/dist/vendor/environment-auth.d.ts +1 -0
- package/dist/vendor/environment-auth.js +4 -4
- package/docs/PROTOCOL.md +378 -23
- package/package.json +1 -1
package/dist/form-runtime.mjs
CHANGED
|
@@ -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 (!
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/html-handoff.d.ts
CHANGED
|
@@ -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. */
|
package/dist/html-handoff.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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() ||
|
package/dist/html-inspect.d.ts
CHANGED
|
@@ -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>;
|