@logicseek/hello-cc 1.1.0-rc.3 → 1.1.0-rc.4
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/CHANGELOG.md +39 -0
- package/docs/native.md +81 -2
- package/docs/native.zh-CN.md +68 -2
- package/lib/cli/commands/native.mjs +42 -8
- package/lib/integrations/native/claude.mjs +29 -3
- package/lib/integrations/native/codex.mjs +38 -9
- package/lib/integrations/native/dsh-acp.mjs +10 -0
- package/lib/integrations/native/interactions.mjs +16 -3
- package/lib/runtime/native/client.mjs +8 -1
- package/lib/runtime/native/service.mjs +189 -28
- package/lib/runtime/native/store.mjs +28 -4
- package/lib/web/browser/core.mjs +9 -1
- package/lib/web/http-routes.mjs +1 -1
- package/lib/web/native-sessions.mjs +53 -13
- package/lib/web/ui-i18n.mjs +6 -0
- package/lib/web/ui-native.mjs +22 -2
- package/lib/web/ui-template.mjs +2 -2
- package/lib/web/ui-workspace.mjs +6 -2
- package/package.json +1 -1
- package/scripts/helpers/gc-clock-observation.mjs +33 -0
- package/scripts/native-installed-acceptance.mjs +80 -19
- package/scripts/regression.mjs +18 -2
- package/scripts/web-native-retry-checks.mjs +177 -0
- package/scripts/web-workbench-acceptance.mjs +7 -4
package/lib/web/ui-i18n.mjs
CHANGED
|
@@ -85,6 +85,7 @@ const coreTranslations = {
|
|
|
85
85
|
'files.select': 'Choose a file to preview.',
|
|
86
86
|
'files.mainWindowRequired': 'Open project files from the main window for this project.',
|
|
87
87
|
'workspace.split': 'Split',
|
|
88
|
+
'workspace.requiresBrowserSession': 'Split requires a signed-in browser session. Restart Web with an access token and sign in.',
|
|
88
89
|
'workspace.panes': 'Session panes',
|
|
89
90
|
'workspace.primary': 'Main',
|
|
90
91
|
'workspace.secondary': 'Second',
|
|
@@ -376,6 +377,7 @@ const coreTranslations = {
|
|
|
376
377
|
'files.select': '选择文件以预览内容。',
|
|
377
378
|
'files.mainWindowRequired': '请在此项目的主窗口中打开项目文件。',
|
|
378
379
|
'workspace.split': '分屏',
|
|
380
|
+
'workspace.requiresBrowserSession': '分屏需要已登录的浏览器会话。请启用访问令牌后重启 Web 并登录。',
|
|
379
381
|
'workspace.panes': '会话窗格',
|
|
380
382
|
'workspace.primary': '主窗格',
|
|
381
383
|
'workspace.secondary': '副窗格',
|
|
@@ -669,6 +671,7 @@ export const UI_TRANSLATIONS = Object.freeze({
|
|
|
669
671
|
'native.output': 'Executor output',
|
|
670
672
|
'native.draft': 'Message draft saved in this browser',
|
|
671
673
|
'native.send': 'Queue message',
|
|
674
|
+
'native.retryPending': 'Retry same submission',
|
|
672
675
|
'native.interrupt': 'Interrupt current turn',
|
|
673
676
|
'native.read': 'Refresh receipts',
|
|
674
677
|
'native.reviewPending': 'Reviewed; keep draft',
|
|
@@ -687,6 +690,7 @@ export const UI_TRANSLATIONS = Object.freeze({
|
|
|
687
690
|
'native.closed': 'Executor closed.',
|
|
688
691
|
'native.retained': 'Draft retained. A new send creates a new submission.',
|
|
689
692
|
'native.reviewConfirm': 'Have you checked the durable receipts? A new send may duplicate work already queued. Keep this draft and remove its unconfirmed marker?',
|
|
693
|
+
'native.retryConfirm': 'Have you inspected the durable receipts? Retry this exact message with its original ID and worker; it will not create a second queue message, but provider execution may already be in progress.',
|
|
690
694
|
'native.delivery.queued': 'Queued, awaiting dispatch',
|
|
691
695
|
'native.delivery.dispatching': 'Submitting to executor',
|
|
692
696
|
'native.delivery.submitted': 'Submitted, awaiting output',
|
|
@@ -931,6 +935,7 @@ export const UI_TRANSLATIONS = Object.freeze({
|
|
|
931
935
|
'native.output': '执行器输出',
|
|
932
936
|
'native.draft': '消息草稿保存在此浏览器',
|
|
933
937
|
'native.send': '将消息加入队列',
|
|
938
|
+
'native.retryPending': '沿用原提交重试',
|
|
934
939
|
'native.interrupt': '中断当前轮次',
|
|
935
940
|
'native.read': '刷新回执',
|
|
936
941
|
'native.reviewPending': '已核对,保留草稿',
|
|
@@ -949,6 +954,7 @@ export const UI_TRANSLATIONS = Object.freeze({
|
|
|
949
954
|
'native.closed': '执行器已关闭。',
|
|
950
955
|
'native.retained': '草稿已保留,再次发送会创建新提交。',
|
|
951
956
|
'native.reviewConfirm': '是否已核对持久回执?再次发送可能重复已经排队的任务。保留草稿并清除未确认标记?',
|
|
957
|
+
'native.retryConfirm': '是否已核对持久回执?本操作会对原 worker 以原 ID 和原消息重试,不会创建第二条排队消息,但 provider 可能已经在执行。',
|
|
952
958
|
'native.delivery.queued': '已排队,等待投递',
|
|
953
959
|
'native.delivery.dispatching': '正在提交给执行器',
|
|
954
960
|
'native.delivery.submitted': '已提交,等待输出',
|
package/lib/web/ui-native.mjs
CHANGED
|
@@ -35,7 +35,7 @@ export function nativePanelHtml() {
|
|
|
35
35
|
<details id="nativeIdentityDetails" class="native-meta"><summary id="nativeIdentityLabel">Executor details</summary><p id="nativeIdentity"></p><p data-i18n="native.approvalPolicy">The current controller can answer interactive requests here.</p></details></div>
|
|
36
36
|
<div id="nativeJumpBar" class="native-reading-jump" hidden><button id="nativeJump" type="button">Back to latest</button></div>
|
|
37
37
|
<footer class="native-composer"><label for="nativeDraft" data-i18n="native.draft">Message draft saved in this browser</label><textarea id="nativeDraft" rows="3"></textarea>${sessionToolsHtml('native')}
|
|
38
|
-
<div class="native-controls"><button id="nativeSend" class="primary" type="button" data-i18n="native.send" disabled>Queue message</button><button id="nativeInterrupt" type="button" data-i18n="native.interrupt" disabled>Interrupt current turn</button><button id="nativeRead" type="button" data-i18n="native.read">Refresh receipts</button><button id="nativeReviewPending" type="button" data-i18n="native.reviewPending" hidden>Reviewed; keep draft</button></div>
|
|
38
|
+
<div class="native-controls"><button id="nativeSend" class="primary" type="button" data-i18n="native.send" disabled>Queue message</button><button id="nativeInterrupt" type="button" data-i18n="native.interrupt" disabled>Interrupt current turn</button><button id="nativeRead" type="button" data-i18n="native.read">Refresh receipts</button><button id="nativeRetryPending" type="button" data-i18n="native.retryPending" hidden>Retry same submission</button><button id="nativeReviewPending" type="button" data-i18n="native.reviewPending" hidden>Reviewed; keep draft</button></div>
|
|
39
39
|
<div class="native-controls"><label><input id="nativeCloseConfirmed" type="checkbox"><span data-i18n="native.closeConfirm">Stop this executor</span></label><button id="nativeClose" class="danger" type="button" data-i18n="native.close" disabled>Close executor</button></div>
|
|
40
40
|
<p id="nativeNotice" role="status" aria-live="polite"></p>
|
|
41
41
|
</footer>
|
|
@@ -82,6 +82,10 @@ export function installNativePanel(installDependencies = true) {
|
|
|
82
82
|
document.getElementById('nativeClose').disabled = !writable || sending || !document.getElementById('nativeCloseConfirmed').checked;
|
|
83
83
|
document.getElementById('nativeReviewPending').hidden = !pending;
|
|
84
84
|
document.getElementById('nativeReviewPending').disabled = sending;
|
|
85
|
+
document.getElementById('nativeRetryPending').hidden = !pending;
|
|
86
|
+
document.getElementById('nativeRetryPending').disabled = !writable || sending || !pending?.owner ||
|
|
87
|
+
pending.owner !== state?.owner || pending.generation !== state?.generation || !pending.text ||
|
|
88
|
+
state?.quarantined || state?.capabilities?.send === false;
|
|
85
89
|
document.getElementById('nativeRead').disabled = bridge().session?.type !== 'native';
|
|
86
90
|
document.getElementById('nativeApprovals').querySelectorAll('button[data-response]').forEach(button => {
|
|
87
91
|
button.disabled = !writable || responding.has(button.dataset.requestKey) || (button.dataset.decision === 'accept' && button.dataset.truncated === 'true');
|
|
@@ -192,7 +196,7 @@ export function installNativePanel(installDependencies = true) {
|
|
|
192
196
|
document.getElementById('nativeSend').addEventListener('click',async()=>{
|
|
193
197
|
sync(); if (document.getElementById('nativeSend').disabled) return;
|
|
194
198
|
const target = subject(), text = draft.value, id = 's_' + crypto.randomUUID(), version = observed;
|
|
195
|
-
pending = {id,text,generation:target.generation}; save(); inflight.add(target.key); sync(); notice(tr('native.sending'));
|
|
199
|
+
pending = {id,text,generation:target.generation,owner:target.owner}; save(); inflight.add(target.key); sync(); notice(tr('native.sending'));
|
|
196
200
|
try {
|
|
197
201
|
const result = await bridge().api(route(target,'send'),{method:'POST',body:JSON.stringify({text,submissionId:id,action_token:target.token,epoch:target.epoch})});
|
|
198
202
|
const receipt = result.receipt || result;
|
|
@@ -202,6 +206,22 @@ export function installNativePanel(installDependencies = true) {
|
|
|
202
206
|
} catch(error) { if (matches(target) && pending?.id === id) notice((error.detail || error.message) + ' ' + tr('native.uncertain')); }
|
|
203
207
|
finally { inflight.delete(target.key); sync(); }
|
|
204
208
|
});
|
|
209
|
+
document.getElementById('nativeRetryPending').addEventListener('click',async()=>{
|
|
210
|
+
sync(); if (document.getElementById('nativeRetryPending').disabled || !pending ||
|
|
211
|
+
!confirm(tr('native.retryConfirm'))) return;
|
|
212
|
+
const target = subject(), original = { ...pending }, version = observed;
|
|
213
|
+
inflight.add(target.key); sync(); notice(tr('native.sending'));
|
|
214
|
+
try {
|
|
215
|
+
const result = await bridge().api(route(target,'send'),{method:'POST',body:JSON.stringify({
|
|
216
|
+
text:original.text,submissionId:original.id,retry:true,action_token:target.token,epoch:target.epoch
|
|
217
|
+
})});
|
|
218
|
+
const receipt = result.receipt || result;
|
|
219
|
+
if (receipt.submission_id !== original.id || !Number.isSafeInteger(receipt.message_id)) throw new Error(tr('native.invalidReceipt'));
|
|
220
|
+
acknowledge(target,original.id);
|
|
221
|
+
if (matches(target) && version === observed) { if (result.state) render(result.state); else void read(); }
|
|
222
|
+
} catch(error) { if (matches(target) && pending?.id === original.id) notice((error.detail || error.message) + ' ' + tr('native.uncertain')); }
|
|
223
|
+
finally { inflight.delete(target.key); sync(); }
|
|
224
|
+
});
|
|
205
225
|
document.getElementById('nativeInterrupt').addEventListener('click',async()=>{
|
|
206
226
|
sync(); if (document.getElementById('nativeInterrupt').disabled) return;
|
|
207
227
|
const target = subject(); inflight.add(target.key); sync();
|
package/lib/web/ui-template.mjs
CHANGED
|
@@ -21,7 +21,7 @@ function nonceAttribute(nonce) {
|
|
|
21
21
|
return ` nonce="${value}"`;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export function webIndexHtml({ nonce, pane = false } = {}) {
|
|
24
|
+
export function webIndexHtml({ nonce, pane = false, paneAllowed = false } = {}) {
|
|
25
25
|
const inlineScriptNonce = nonceAttribute(nonce);
|
|
26
26
|
return `<!doctype html>
|
|
27
27
|
<html lang="en">
|
|
@@ -625,7 +625,7 @@ export function webIndexHtml({ nonce, pane = false } = {}) {
|
|
|
625
625
|
${agentDefaultsCss}
|
|
626
626
|
</style>
|
|
627
627
|
</head>
|
|
628
|
-
<body>
|
|
628
|
+
<body data-workspace-pane-allowed="${paneAllowed === true}">
|
|
629
629
|
<header class="global-header">
|
|
630
630
|
<strong>hello-cc</strong>
|
|
631
631
|
<div class="header-controls">
|
package/lib/web/ui-workspace.mjs
CHANGED
|
@@ -92,6 +92,7 @@ function workspaceRuntime(normalize, validMessage) {
|
|
|
92
92
|
const workspace = byId('workspace'), panes = byId('workspacePanes'), bar = byId('workspaceBar');
|
|
93
93
|
const secondary = byId('workspaceSecondPane'), status = byId('workspaceStatus'), select = byId('workspaceSession');
|
|
94
94
|
const divider = byId('workspaceDivider'), split = byId('splitBtn');
|
|
95
|
+
const paneAllowed = host.paneAllowed !== false;
|
|
95
96
|
const mobile = window.matchMedia('(max-width:1099px)');
|
|
96
97
|
let root = '', layout = normalize(null), frame = null, mounted = '', timer = 0, selectedPane = 'primary', resizing = false, applyingParentPreferences = false;
|
|
97
98
|
const key = () => 'hcc.workspace:' + root;
|
|
@@ -123,7 +124,7 @@ function workspaceRuntime(normalize, validMessage) {
|
|
|
123
124
|
host.layoutChanged();
|
|
124
125
|
}
|
|
125
126
|
function mount() {
|
|
126
|
-
if (!layout.open || !host.ready()) return;
|
|
127
|
+
if (!paneAllowed || !layout.open || !host.ready()) return;
|
|
127
128
|
const session = host.sessions().find(item => item.id === layout.session && item.status === 'running');
|
|
128
129
|
if (!session) {
|
|
129
130
|
destroy(); status.hidden = false; status.textContent = tr('workspace.missing'); return;
|
|
@@ -145,7 +146,10 @@ function workspaceRuntime(normalize, validMessage) {
|
|
|
145
146
|
try { layout = normalize(JSON.parse(ui.safeGet(key()) || 'null')); } catch (_) { layout = normalize(null); }
|
|
146
147
|
selectedPane = 'primary';
|
|
147
148
|
}
|
|
148
|
-
|
|
149
|
+
// Keep saved layouts for a later signed-in page, without mounting a denied frame.
|
|
150
|
+
if (!paneAllowed) { layout.open = false; selectedPane = 'primary'; destroy(); }
|
|
151
|
+
split.disabled = !paneAllowed || !host.ready() || !host.sessions().some(item => item.status === 'running');
|
|
152
|
+
split.setAttribute('title', tr(paneAllowed ? 'workspace.split' : 'workspace.requiresBrowserSession'));
|
|
149
153
|
const options = host.sessions().filter(item => item.status === 'running');
|
|
150
154
|
const esc = host.esc;
|
|
151
155
|
const html = options.map(item => '<option value="'+esc(item.id)+'">'+esc(item.name || item.task?.title || item.peer_id || item.id)+'</option>').join('');
|
package/package.json
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
const observedAtKey = 'clock_last_observed_at';
|
|
4
|
+
|
|
5
|
+
function timestamp(value, label) {
|
|
6
|
+
assert.ok(Number.isSafeInteger(value) && value >= 0, `${label} must be a nonnegative safe integer`);
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function observedAt(rows, label) {
|
|
11
|
+
const matches = rows.filter(row => row.key === observedAtKey);
|
|
12
|
+
assert.equal(matches.length, 1, `${label} must contain exactly one clock observation`);
|
|
13
|
+
assert.match(matches[0].value, /^(0|[1-9]\d*)$/, `${label} clock observation must be a canonical timestamp`);
|
|
14
|
+
return timestamp(Number(matches[0].value), `${label} clock observation`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// An active runtime observes its clock even when its reaper has no candidates.
|
|
18
|
+
// Only that watermark may advance while the manual GC preview is in flight.
|
|
19
|
+
export function assertGcPreviewMetadata(before, after, { startedAtSec, finishedAtSec }) {
|
|
20
|
+
timestamp(startedAtSec, 'preview start');
|
|
21
|
+
timestamp(finishedAtSec, 'preview finish');
|
|
22
|
+
assert.ok(finishedAtSec >= startedAtSec, 'preview time window must be monotonic');
|
|
23
|
+
assert.deepEqual(
|
|
24
|
+
after.filter(row => row.key !== observedAtKey),
|
|
25
|
+
before.filter(row => row.key !== observedAtKey),
|
|
26
|
+
'manual GC preview changed metadata other than the runtime clock observation'
|
|
27
|
+
);
|
|
28
|
+
const previous = observedAt(before, 'before preview');
|
|
29
|
+
const current = observedAt(after, 'after preview');
|
|
30
|
+
assert.ok(previous >= startedAtSec && previous <= finishedAtSec, 'initial clock observation is outside the preview time window');
|
|
31
|
+
assert.ok(current >= previous, 'runtime clock observation moved backward during the preview');
|
|
32
|
+
assert.ok(current <= finishedAtSec, 'runtime clock observation is beyond the preview time window');
|
|
33
|
+
}
|
|
@@ -13,6 +13,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
13
13
|
import { DatabaseSync } from 'node:sqlite';
|
|
14
14
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
15
15
|
import { nativeRequest } from '../lib/runtime/native/client.mjs';
|
|
16
|
+
import { resolveProjectDatabase } from '../lib/runtime/project-path.mjs';
|
|
16
17
|
import { readNativePointer, nativePaths } from '../lib/runtime/native/store.mjs';
|
|
17
18
|
import { stabilityOptions, assertStabilityEvidence, stabilityPayload } from './native-stability-checks.mjs';
|
|
18
19
|
import { redactSecrets } from '../lib/shared/redact.mjs';
|
|
@@ -39,7 +40,8 @@ const initialSources = sourceHashes();
|
|
|
39
40
|
const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-installed-native-')));
|
|
40
41
|
fs.chmodSync(sandbox, 0o700);
|
|
41
42
|
const root = path.join(sandbox, 'project');
|
|
42
|
-
|
|
43
|
+
// Resolve the database only after this process shares the CLI's private HOME.
|
|
44
|
+
const ctx = { root, cwd: root, dbPath: null };
|
|
43
45
|
const output = path.resolve(option('--output', path.join(sandbox, 'receipt.json')));
|
|
44
46
|
const report = { startedAt: new Date().toISOString(), scope: 'installed public CLI, authenticated lifecycle, cross-provider messages, optional browser, bounded coding task and opt-in sustained stability',
|
|
45
47
|
package: { directory: repo, version: JSON.parse(fs.readFileSync(path.join(repo, 'package.json'))).version,
|
|
@@ -47,7 +49,10 @@ const report = { startedAt: new Date().toISOString(), scope: 'installed public C
|
|
|
47
49
|
node: process.version, sandbox, providers: {}, checks: [], cleanup: {}, sourceFiles: initialSources,
|
|
48
50
|
acceptanceMode: stabilityOnly ? 'stability-only' : 'full', factoryInjected: false, queryInjected: false, authentication: 'existing route copied into test-owned private homes', screenshots: [], pageErrors: [], consoleErrors: [], limitations: [] };
|
|
49
51
|
const credentials = [];
|
|
50
|
-
const
|
|
52
|
+
const credentialIdentities = new Map();
|
|
53
|
+
const originalHomeEnv = process.env.HOME;
|
|
54
|
+
const originalUserHome = fs.realpathSync(os.homedir());
|
|
55
|
+
const originalCodexHome = path.resolve(process.env.CODEX_HOME || path.join(originalUserHome, '.codex'));
|
|
51
56
|
const protectedFiles = ['config.toml', 'auth.json'].map(name => path.join(originalCodexHome, name)).filter(file => fs.existsSync(file));
|
|
52
57
|
const protectedHashes = protectedFiles.map(file => [file, digest(file)]);
|
|
53
58
|
let env, db, browser, runtimeStarted = false, webStarted = false;
|
|
@@ -58,14 +63,42 @@ const socket = `hcc-installed-${randomUUID()}`;
|
|
|
58
63
|
const terminal = new Set(['completed', 'failed', 'uncertain']);
|
|
59
64
|
function check(name, details = {}) { report.checks.push({ name, passed: true, ...details }); console.log(JSON.stringify({ check: name, ...details })); }
|
|
60
65
|
function flush() { fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, JSON.stringify(redactSecrets(report), null, 2) + '\n', { mode: 0o600 }); }
|
|
61
|
-
function
|
|
66
|
+
function rememberCredentialDirectory(directory) {
|
|
67
|
+
const stat = fs.lstatSync(directory);
|
|
68
|
+
assert.ok(stat.isDirectory() && !stat.isSymbolicLink(), 'Credential cleanup needs an owned directory');
|
|
69
|
+
credentials.push(directory);
|
|
70
|
+
credentialIdentities.set(directory, { canonical: fs.realpathSync(directory), dev: stat.dev, ino: stat.ino });
|
|
71
|
+
return directory;
|
|
72
|
+
}
|
|
73
|
+
function privateDir(name) {
|
|
74
|
+
const directory = path.join(sandbox, name);
|
|
75
|
+
fs.mkdirSync(directory, { mode: 0o700 });
|
|
76
|
+
return rememberCredentialDirectory(directory);
|
|
77
|
+
}
|
|
78
|
+
function removeCredentialDirectory(directory) {
|
|
79
|
+
const saved = credentialIdentities.get(directory);
|
|
80
|
+
let current;
|
|
81
|
+
try { current = fs.lstatSync(directory); }
|
|
82
|
+
catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
83
|
+
// Refuse a replaced directory or alias before deleting test credentials.
|
|
84
|
+
assert.ok(saved && current.isDirectory() && !current.isSymbolicLink() &&
|
|
85
|
+
current.dev === saved.dev && current.ino === saved.ino && fs.realpathSync(directory) === saved.canonical,
|
|
86
|
+
'Test credential directory changed identity; retained it for inspection');
|
|
87
|
+
fs.rmSync(directory, { recursive: true });
|
|
88
|
+
}
|
|
62
89
|
function hcc(...params) {
|
|
63
90
|
const result = spawnSync(process.execPath, [cli, '--root', root, '--json', ...params], { cwd: root, env, encoding: 'utf8', timeout: 45000 });
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
91
|
+
const command = params[0] === 'native' ? `native ${params[1]}` : params[0];
|
|
92
|
+
const failure = code => Object.assign(new Error(`Installed CLI ${command} status=${Number.isInteger(result.status) ? result.status : 'unavailable'} code=${code}`), { code });
|
|
93
|
+
// CLI/provider output can contain credentials or arbitrary payloads. Keep
|
|
94
|
+
// failure receipts structural, including malformed successful-CLI output.
|
|
95
|
+
if (result.error || result.status !== 0) throw failure('HCC_CLI_EXIT_FAILED');
|
|
96
|
+
const stdout = typeof result.stdout === 'string' ? result.stdout.trim() : '';
|
|
97
|
+
if (params[0] !== 'native') return stdout ? { output: stdout } : null;
|
|
98
|
+
let reply;
|
|
99
|
+
try { reply = JSON.parse(stdout); }
|
|
100
|
+
catch { throw failure('HCC_CLI_REPLY_INVALID'); }
|
|
101
|
+
if (!reply || typeof reply !== 'object' || reply.ok !== true) throw failure('HCC_CLI_REPLY_REJECTED');
|
|
69
102
|
return reply.data;
|
|
70
103
|
}
|
|
71
104
|
const api = (method, route, body = null) => nativeRequest(ctx, method, route, body, { timeoutMs: 45000 });
|
|
@@ -159,8 +192,8 @@ async function lifecycle(provider) {
|
|
|
159
192
|
// this test-owned saved session before the subsequent communication checks.
|
|
160
193
|
hcc('native', 'close', '--peer', peer); await start(provider, true);
|
|
161
194
|
if (provider === 'codex') {
|
|
162
|
-
const boundary = fs.mkdtempSync(path.join(
|
|
163
|
-
|
|
195
|
+
const boundary = fs.mkdtempSync(path.join(originalUserHome, '.hcc-installed-permission-'));
|
|
196
|
+
fs.chmodSync(boundary, 0o700); rememberCredentialDirectory(boundary);
|
|
164
197
|
const target = path.join(boundary, 'denied.txt');
|
|
165
198
|
const probe = message(peer, `Use the command tool with sandbox_permissions=require_escalated to write PROOF to ${target}. Request approval. If declined, stop without any workaround. Read no files.`);
|
|
166
199
|
const request = await until(async () => (await state(peer)).snapshot.pendingApprovals?.[0], 'Codex real command approval');
|
|
@@ -452,7 +485,7 @@ async function codingTask() {
|
|
|
452
485
|
const testFile = path.join(root, 'invoice.test.mjs');
|
|
453
486
|
fs.writeFileSync(testFile, `import { strict as assert } from 'node:assert';\nimport { test } from 'node:test';\nimport { invoiceTotal } from './invoice.mjs';\ntest('empty invoice', () => assert.deepEqual(invoiceTotal([], 0), { subtotalCents: 0, taxCents: 0, totalCents: 0 }));\ntest('multiple lines and tax rounding', () => assert.deepEqual(invoiceTotal([{ unitCents: 199, quantity: 3 }, { unitCents: 500, quantity: 2 }], 825), { subtotalCents: 1597, taxCents: 132, totalCents: 1729 }));\ntest('half-cent rounds up', () => assert.equal(invoiceTotal([{ unitCents: 1, quantity: 1 }], 5000).taxCents, 1));\ntest('reject fractional/negative values', () => { for (const line of [{unitCents: -1, quantity: 1}, {unitCents: 10, quantity: 0}, {unitCents: 1.5, quantity: 1}]) assert.throws(() => invoiceTotal([line], 0)); assert.throws(() => invoiceTotal([], 10001)); });\ntest('reject total overflow', () => assert.throws(() => invoiceTotal([{ unitCents: Number.MAX_SAFE_INTEGER, quantity: 2 }], 0)));\n`);
|
|
454
487
|
const contractSha256 = digest(testFile);
|
|
455
|
-
const spec = `Implement invoice.mjs exporting invoiceTotal(lines, taxBasisPoints). All money is integer cents. unitCents is a nonnegative safe integer, quantity a positive safe integer, taxBasisPoints an integer 0..10000. Throw on invalid inputs or safe-integer overflow. Return {subtotalCents,taxCents,totalCents}. Tax rounds the subtotal times basis points divided by 10000 to nearest cent, halves up; use exact integer arithmetic. Read invoice.test.mjs but do not modify it. Only write invoice.mjs using the file edit/apply_patch tool. You may read invoice.test.mjs with a file read tool or one cat command; do not run tests or any other commands. End with IMPLEMENTED.`;
|
|
488
|
+
const spec = `Implement invoice.mjs exporting invoiceTotal(lines, taxBasisPoints). All money is integer cents. unitCents is a nonnegative safe integer, quantity a positive safe integer, taxBasisPoints an integer 0..10000. Throw on invalid inputs or safe-integer overflow. Return {subtotalCents,taxCents,totalCents}. Tax rounds the subtotal times basis points divided by 10000 to nearest cent, halves up; use exact integer arithmetic. Read invoice.test.mjs but do not modify it. Only write invoice.mjs using the file edit/apply_patch tool. If only a command tool is exposed, you may invoke apply_patch through it to create invoice.mjs; this is the only permitted write command. You may read invoice.test.mjs with a file read tool or one cat command; do not run tests or any other commands. End with IMPLEMENTED.`;
|
|
456
489
|
const submitted = message('install-codex', spec);
|
|
457
490
|
await finish('install-codex', submitted, request => {
|
|
458
491
|
const files = request.params?.changes || request.params?.toolCall?.rawInput;
|
|
@@ -529,10 +562,11 @@ try {
|
|
|
529
562
|
if (!command) return line;
|
|
530
563
|
const original = command[1].startsWith("'") ? command[1].slice(1, -1) : JSON.parse(command[1]);
|
|
531
564
|
const wrapper = path.join(authCommands, `command-${++wrappedCommands}`);
|
|
532
|
-
fs.writeFileSync(wrapper, `#!/bin/sh\nexport HOME=${quoteAuth(
|
|
565
|
+
fs.writeFileSync(wrapper, `#!/bin/sh\nexport HOME=${quoteAuth(originalUserHome)}\nexec ${quoteAuth(original)} "$@"\n`, { mode: 0o700 });
|
|
533
566
|
return `command = ${JSON.stringify(wrapper)}`;
|
|
534
567
|
}).join('\n');
|
|
535
568
|
report.authenticationHelperHomePreserved = wrappedCommands > 0;
|
|
569
|
+
if (wrappedCommands > 0) report.limitations.push('Authentication helpers retain the original caller HOME to reuse its existing account route. Private provider homes do not isolate those helpers from original-home reads/writes or external side effects.');
|
|
536
570
|
fs.writeFileSync(path.join(homes.codex, 'config.toml'), isolatedConfig + '\n', { mode: 0o600 });
|
|
537
571
|
}
|
|
538
572
|
const auth = path.join(originalCodexHome, 'auth.json');
|
|
@@ -548,8 +582,17 @@ try {
|
|
|
548
582
|
env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('HCC_')));
|
|
549
583
|
Object.assign(env, { PATH: `${bin}:${path.dirname(process.execPath)}:${process.env.PATH}`, CODEX_HOME: homes.codex, CLAUDE_CONFIG_DIR: homes.claude,
|
|
550
584
|
HOME: userHome, DSH_HOME: homes.dsh, DSH_TELEMETRY_DISABLED: '1', HCC_SHIM_ENSURED: '1', HCC_SHIM_NO_ATTACH: '1', HCC_NO_AUTO_INSTALL_TMUX: '1', HCC_WEB_TOKEN: randomUUID() });
|
|
585
|
+
// In-process pointer/database readers and public CLI children must select
|
|
586
|
+
// the same private project state. Authentication sources above remain bound
|
|
587
|
+
// to the captured original home; only this test process changes HOME.
|
|
588
|
+
process.env.HOME = userHome;
|
|
589
|
+
const resolved = resolveProjectDatabase({ root, createStateDir: false });
|
|
590
|
+
ctx.dbPath = resolved.db;
|
|
591
|
+
report.projectState = { dbPath: ctx.dbPath, stateDirectory: resolved.stateDir, privateHome: userHome };
|
|
551
592
|
report.hccGlobalHomeIsolated = true;
|
|
552
|
-
hcc('native', 'up'); runtimeStarted = true;
|
|
593
|
+
const started = hcc('native', 'up'); runtimeStarted = true;
|
|
594
|
+
assert.equal(started.meshDb, ctx.dbPath, 'CLI and acceptance process must use the same project database');
|
|
595
|
+
assert.ok(fs.existsSync(ctx.dbPath), 'Public CLI must initialize the project database');
|
|
553
596
|
db = new DatabaseSync(ctx.dbPath, { readOnly: true });
|
|
554
597
|
check('locally installed package starts its own native daemon through the public CLI', { runtimePid: readNativePointer(ctx)?.pid });
|
|
555
598
|
if (stabilityOnly) {
|
|
@@ -591,15 +634,33 @@ try {
|
|
|
591
634
|
} finally {
|
|
592
635
|
await browser?.close().catch(() => {});
|
|
593
636
|
if (webStarted) { try { hcc('down'); } catch {} }
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
637
|
+
try {
|
|
638
|
+
// A successful daemon can outlive a failed/lost `native up` CLI receipt.
|
|
639
|
+
// Inspect only this test's HOME/root/database pointer before deciding it
|
|
640
|
+
// needs shutdown, even when runtimeStarted was never set.
|
|
641
|
+
const pointer = env && ctx.dbPath ? readNativePointer(ctx) : null;
|
|
642
|
+
if (env && (runtimeStarted || pointer)) {
|
|
643
|
+
hcc('native', 'down');
|
|
644
|
+
await until(() => !readNativePointer(ctx), 'native daemon shutdown', 30000);
|
|
645
|
+
}
|
|
646
|
+
report.cleanup.runtimeStopped = !ctx.dbPath || !readNativePointer(ctx);
|
|
647
|
+
} catch (error) {
|
|
648
|
+
report.cleanup.runtimeStopped = false; report.cleanup.error = error.message;
|
|
649
|
+
process.exitCode = 1; report.completed = false;
|
|
650
|
+
}
|
|
598
651
|
db?.close();
|
|
599
652
|
spawnSync(tmux, ['-L', socket, 'kill-server'], { stdio: 'ignore' });
|
|
600
653
|
report.cleanup.protectedConfigUnchanged = protectedHashes.every(([file, hash]) => fs.existsSync(file) && digest(file) === hash);
|
|
601
|
-
|
|
602
|
-
|
|
654
|
+
const credentialCleanupErrors = [];
|
|
655
|
+
for (const directory of credentials) {
|
|
656
|
+
try { removeCredentialDirectory(directory); }
|
|
657
|
+
catch (error) { credentialCleanupErrors.push({ directory, message: error.message }); }
|
|
658
|
+
}
|
|
659
|
+
report.cleanup.temporaryCredentialsRemoved = credentialCleanupErrors.length === 0 &&
|
|
660
|
+
credentials.every(directory => !fs.existsSync(directory));
|
|
661
|
+
if (credentialCleanupErrors.length) report.cleanup.credentialErrors = credentialCleanupErrors;
|
|
662
|
+
if (originalHomeEnv === undefined) delete process.env.HOME;
|
|
663
|
+
else process.env.HOME = originalHomeEnv;
|
|
603
664
|
const finalSources = sourceHashes();
|
|
604
665
|
report.sourceChangesDuringRun = Object.keys({ ...initialSources, ...finalSources }).filter(file => initialSources[file] !== finalSources[file]);
|
|
605
666
|
report.sourceUnchangedDuringRun = report.sourceChangesDuringRun.length === 0;
|
package/scripts/regression.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { createCookieExpiryWindow } from './regression-cookie-expiry.mjs';
|
|
|
22
22
|
import { waitForTerminalMarker, terminalMarkerFailureDiagnostic } from './regression-terminal-marker.mjs';
|
|
23
23
|
import { fixtureFetch as fetch } from './regression-http-transport.mjs';
|
|
24
24
|
import { parseShutdownDiagnostics } from '../lib/web/shutdown-diagnostics.mjs';
|
|
25
|
+
import { assertGcPreviewMetadata } from './helpers/gc-clock-observation.mjs';
|
|
25
26
|
|
|
26
27
|
const repoRoot = path.resolve(import.meta.dirname, '..');
|
|
27
28
|
const hccBin = path.join(repoRoot, 'bin', 'hcc.mjs');
|
|
@@ -6179,6 +6180,7 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
6179
6180
|
} finally {
|
|
6180
6181
|
unifiedDb.close();
|
|
6181
6182
|
}
|
|
6183
|
+
const gapPreviewStartedAtSec = Math.floor(Date.now() / 1000);
|
|
6182
6184
|
setSiblingClockGap(Math.floor(fs.statSync(gapOrphan).mtimeMs / 1000));
|
|
6183
6185
|
const beforeGapPreviewDb = new DatabaseSync(path.join(secondProjectRoot, '.hello-cc', 'mesh.db'), { timeout: 5000 });
|
|
6184
6186
|
let beforeGapMeta;
|
|
@@ -6191,8 +6193,11 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
6191
6193
|
const afterGapPreviewDb = new DatabaseSync(path.join(secondProjectRoot, '.hello-cc', 'mesh.db'), { timeout: 5000 });
|
|
6192
6194
|
try {
|
|
6193
6195
|
const afterGapMeta = afterGapPreviewDb.prepare('SELECT key, value FROM meta ORDER BY key').all();
|
|
6194
|
-
|
|
6195
|
-
|
|
6196
|
+
assertGcPreviewMetadata(beforeGapMeta, afterGapMeta, {
|
|
6197
|
+
startedAtSec: gapPreviewStartedAtSec,
|
|
6198
|
+
finishedAtSec: Math.floor(Date.now() / 1000)
|
|
6199
|
+
});
|
|
6200
|
+
if (!afterGapPreviewDb.prepare('SELECT 1 FROM events WHERE id = ?').get(unifiedIds.event) ||
|
|
6196
6201
|
!afterGapPreviewDb.prepare("SELECT 1 FROM locks WHERE resource = 'gc-unified-lock'").get() ||
|
|
6197
6202
|
!afterGapPreviewDb.prepare("SELECT 1 FROM peers WHERE id = 'gc-unified-dead'").get()) {
|
|
6198
6203
|
fail(`manual GC dry-run changed unified clock state: ${JSON.stringify({ beforeGapMeta, afterGapMeta })}`);
|
|
@@ -11097,6 +11102,13 @@ function manualGcRetentionContractWorkflow() {
|
|
|
11097
11102
|
}
|
|
11098
11103
|
if (fs.existsSync(ordinaryBuffer)) fail('ordinary GC did not remove eligible technical buffer state');
|
|
11099
11104
|
|
|
11105
|
+
const beforeDryRun = new DatabaseSync(gcDbPath, { timeout: 5000 });
|
|
11106
|
+
let beforeDryRunMeta;
|
|
11107
|
+
try {
|
|
11108
|
+
beforeDryRunMeta = beforeDryRun.prepare('SELECT key, value FROM meta ORDER BY key').all();
|
|
11109
|
+
} finally {
|
|
11110
|
+
beforeDryRun.close();
|
|
11111
|
+
}
|
|
11100
11112
|
const historyDryRun = readJson(['gc', '--older-than', '0', '--history']);
|
|
11101
11113
|
for (const key of ['old_events', 'old_tasks', 'old_messages', 'old_handoffs']) {
|
|
11102
11114
|
if (Number(historyDryRun[key] || 0) < 1) {
|
|
@@ -11108,6 +11120,10 @@ function manualGcRetentionContractWorkflow() {
|
|
|
11108
11120
|
}
|
|
11109
11121
|
const afterDryRun = new DatabaseSync(gcDbPath, { timeout: 5000 });
|
|
11110
11122
|
try {
|
|
11123
|
+
const afterDryRunMeta = afterDryRun.prepare('SELECT key, value FROM meta ORDER BY key').all();
|
|
11124
|
+
if (JSON.stringify(afterDryRunMeta) !== JSON.stringify(beforeDryRunMeta)) {
|
|
11125
|
+
fail(`CLI-only history dry-run changed persisted metadata: ${JSON.stringify({ beforeDryRunMeta, afterDryRunMeta })}`);
|
|
11126
|
+
}
|
|
11111
11127
|
if (!rowExists(afterDryRun, 'messages', seeded.message) ||
|
|
11112
11128
|
!rowExists(afterDryRun, 'handoffs', seeded.handoff)) {
|
|
11113
11129
|
fail('history dry-run changed persisted history');
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Real HTTP/browser retry checks against test-owned deterministic adapters only.
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
|
|
6
|
+
const FAILURE_CODE = 'NATIVE_ACCEPTANCE_COMMIT_GAP';
|
|
7
|
+
const hash = text => createHash('sha256').update(text).digest('hex');
|
|
8
|
+
function readMesh(dbPath, read) {
|
|
9
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
10
|
+
try { return read(db); } finally { db.close(); }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createNativeRetryFault({ dbPath }) {
|
|
14
|
+
const armed = new Set(), injected = [];
|
|
15
|
+
return {
|
|
16
|
+
injected,
|
|
17
|
+
arm(peer) {
|
|
18
|
+
assert.ok(peer.startsWith('qa-retry-'), 'Only acceptance-owned peers may receive the commit-gap fault');
|
|
19
|
+
assert.ok(!armed.has(peer) && !injected.some(value => value.peer === peer), 'Inject once per owned worker');
|
|
20
|
+
armed.add(peer);
|
|
21
|
+
},
|
|
22
|
+
afterMeshCommit({ submissionId, messageId }) {
|
|
23
|
+
if (!armed.size) return;
|
|
24
|
+
const intent = readMesh(dbPath, db => db.prepare(`SELECT u.peer,u.submission_id,u.message_id,m.body
|
|
25
|
+
FROM native_user_submissions u JOIN messages m ON m.id=u.message_id
|
|
26
|
+
WHERE u.submission_id=? AND u.message_id=?`).get(submissionId, messageId));
|
|
27
|
+
if (!intent || !armed.has(intent.peer)) return;
|
|
28
|
+
armed.delete(intent.peer);
|
|
29
|
+
injected.push({ peer: intent.peer, submissionId, messageId, bodySha256: hash(intent.body) });
|
|
30
|
+
throw Object.assign(new Error('Acceptance fault after durable mesh commit and before delivery projection'), { code: FAILURE_CODE });
|
|
31
|
+
},
|
|
32
|
+
assertConsumed() { assert.equal(armed.size, 0, 'Every armed commit-gap injection must be reached'); }
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function assertExpectedBrowserErrors(evidence) {
|
|
37
|
+
const matched = new Set();
|
|
38
|
+
for (const expected of evidence.expectedErrors) {
|
|
39
|
+
assert.equal(expected.status, 409);
|
|
40
|
+
assert.equal(expected.code, FAILURE_CODE);
|
|
41
|
+
assert.ok(expected.url && expected.responseVerified, 'Expected errors require a verified actual response URL');
|
|
42
|
+
const indices = evidence.console.flatMap((entry, index) => entry.location?.url === expected.url &&
|
|
43
|
+
entry.phase === expected.phase &&
|
|
44
|
+
entry.text === 'Failed to load resource: the server responded with a status of 409 (Conflict)' ? [index] : []);
|
|
45
|
+
assert.equal(indices.length, 1, 'Expect exactly one Chromium resource error for the injected URL and phase');
|
|
46
|
+
assert.ok(!matched.has(indices[0]), 'An expected response cannot consume another expected console entry');
|
|
47
|
+
expected.consoleIndex = indices[0]; matched.add(indices[0]);
|
|
48
|
+
}
|
|
49
|
+
evidence.unexpectedConsole = evidence.console.filter((_, index) => !matched.has(index));
|
|
50
|
+
assert.deepEqual(evidence.unexpectedConsole, [], 'Every non-injected browser warning/error must remain a failure');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function runNativeRetryChecks({ browser, base, token, root, dbPath, adaptersByPeer, api,
|
|
54
|
+
service, retryFault, select, check, shot, watch, browserInstrumentation, evidence }) {
|
|
55
|
+
const receipt = evidence.nativeRetry = { modelCalls: false, scenarios: [],
|
|
56
|
+
fault: 'One actual HTTP 409 after mesh message/intent commit, before native delivery projection, per viewport',
|
|
57
|
+
pollIntervalMs: 1000000 };
|
|
58
|
+
for (const viewport of [{ name: 'desktop', width: 1440, height: 1000 }, { name: 'mobile', width: 390, height: 844 }]) {
|
|
59
|
+
const peer = 'qa-retry-' + viewport.name, original = 'Original retry message for ' + viewport.name;
|
|
60
|
+
const edited = 'Later unsent draft for ' + viewport.name;
|
|
61
|
+
const route = '/api/sessions/' + peer + '/native/send';
|
|
62
|
+
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
|
63
|
+
await context.addInitScript(browserInstrumentation);
|
|
64
|
+
const page = await context.newPage(); watch(page);
|
|
65
|
+
const requests = [], responses = [];
|
|
66
|
+
page.on('request', request => {
|
|
67
|
+
if (request.method() === 'POST' && new URL(request.url()).pathname === route) requests.push(request.postDataJSON());
|
|
68
|
+
});
|
|
69
|
+
page.on('response', response => {
|
|
70
|
+
if (response.request().method() === 'POST' && new URL(response.url()).pathname === route) responses.push(response.status());
|
|
71
|
+
});
|
|
72
|
+
let observerContext, workerStarted = false, failure;
|
|
73
|
+
try {
|
|
74
|
+
evidence.phase = 'native retry ' + viewport.name + ' commit-gap response';
|
|
75
|
+
await api('POST', '/workers', { peer, provider: 'codex' });
|
|
76
|
+
workerStarted = true;
|
|
77
|
+
await page.goto(base + '/?token=' + token); await select(page, peer);
|
|
78
|
+
if (!await page.evaluate(() => window.hccHandoff.canControl)) await page.locator('#claimControlBtn').click();
|
|
79
|
+
await page.waitForFunction(() => window.hccHandoff.canControl);
|
|
80
|
+
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
81
|
+
if (viewport.width === 390) await page.locator('.mobile-nav [data-view="terminal"]').click();
|
|
82
|
+
const originalState = await api('GET', '/workers/' + peer + '/state');
|
|
83
|
+
const adapter = adaptersByPeer.get(peer);
|
|
84
|
+
assert.equal(adapter.sent || 0, 0);
|
|
85
|
+
retryFault.arm(peer);
|
|
86
|
+
const expected = { phase: evidence.phase, status: 409, code: FAILURE_CODE, url: null, responseVerified: false };
|
|
87
|
+
evidence.expectedErrors.push(expected);
|
|
88
|
+
await page.locator('#nativeDraft').fill(original);
|
|
89
|
+
const firstResponse = page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === route);
|
|
90
|
+
await page.locator('#nativeSend').click();
|
|
91
|
+
const failed = await firstResponse;
|
|
92
|
+
expected.url = failed.url(); assert.equal(failed.status(), 409);
|
|
93
|
+
assert.equal((await failed.json()).error.code, FAILURE_CODE); expected.responseVerified = true;
|
|
94
|
+
await page.waitForFunction(() => !document.getElementById('nativeRetryPending').hidden && !document.getElementById('nativeRetryPending').disabled);
|
|
95
|
+
assert.equal(requests.length, 1);
|
|
96
|
+
const submissionId = requests[0].submissionId;
|
|
97
|
+
assert.equal(requests[0].text, original); assert.equal(requests[0].retry, undefined);
|
|
98
|
+
const readPending = () => page.evaluate(({ root, peer }) => JSON.parse(localStorage.getItem('hcc.nativeDraft:' + JSON.stringify([root, peer])) || '{}'), { root, peer });
|
|
99
|
+
const saved = await readPending();
|
|
100
|
+
assert.equal(saved.text, original); assert.equal(saved.pending.id, submissionId);
|
|
101
|
+
assert.equal(saved.pending.text, original); assert.equal(saved.pending.owner, originalState.owner);
|
|
102
|
+
assert.equal(saved.pending.generation, originalState.generation);
|
|
103
|
+
assert.equal((await api('GET', '/workers/' + peer + '/state')).deliveries.length, 0);
|
|
104
|
+
assert.equal(adapter.sent || 0, 0, 'Automatic polling must not repair the deliberately missing projection');
|
|
105
|
+
const message = readMesh(dbPath, db => db.prepare('SELECT message_id FROM native_user_submissions WHERE submission_id=?').get(submissionId));
|
|
106
|
+
assert.ok(message);
|
|
107
|
+
await page.locator('#nativeDraft').fill(edited);
|
|
108
|
+
await shot(page, viewport.name + '-native-retry-pending');
|
|
109
|
+
|
|
110
|
+
observerContext = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
111
|
+
await observerContext.addInitScript(browserInstrumentation);
|
|
112
|
+
const observer = await observerContext.newPage(); watch(observer);
|
|
113
|
+
await observer.goto(base + '/?token=' + token); await select(observer, peer);
|
|
114
|
+
await observer.locator('#claimControlBtn').click();
|
|
115
|
+
await observer.waitForFunction(() => window.hccHandoff.canControl);
|
|
116
|
+
await page.waitForFunction(() => !window.hccHandoff.canControl);
|
|
117
|
+
assert.equal(await page.locator('#nativeRetryPending').isDisabled(), true);
|
|
118
|
+
await page.locator('#nativeRetryPending').evaluate(button => button.click());
|
|
119
|
+
assert.equal(requests.length, 1, 'An observer cannot send a retry');
|
|
120
|
+
await page.locator('#claimControlBtn').click();
|
|
121
|
+
await page.waitForFunction(() => window.hccHandoff.canControl && !document.getElementById('nativeRetryPending').disabled);
|
|
122
|
+
const lease = await page.evaluate(() => ({ token: window.hccHandoff.actionToken, epoch: window.hccHandoff.epoch }));
|
|
123
|
+
const retryResponse = page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === route);
|
|
124
|
+
await page.locator('#nativeRetryPending').click();
|
|
125
|
+
const repaired = await retryResponse;
|
|
126
|
+
assert.equal(repaired.status(), 200);
|
|
127
|
+
const result = await repaired.json();
|
|
128
|
+
assert.equal(result.receipt.submission_id, submissionId); assert.equal(result.receipt.message_id, message.message_id);
|
|
129
|
+
assert.equal(requests.length, 2);
|
|
130
|
+
assert.equal(requests[1].text, original); assert.equal(requests[1].submissionId, submissionId); assert.equal(requests[1].retry, true);
|
|
131
|
+
assert.equal(requests[1].action_token, lease.token); assert.equal(requests[1].epoch, lease.epoch);
|
|
132
|
+
await page.waitForFunction(() => document.getElementById('nativeRetryPending').hidden);
|
|
133
|
+
assert.equal(await page.locator('#nativeDraft').inputValue(), edited);
|
|
134
|
+
assert.deepEqual(await readPending(), { text: edited, pending: null });
|
|
135
|
+
const counts = readMesh(dbPath, db => ({
|
|
136
|
+
messages: db.prepare('SELECT COUNT(*) AS n FROM messages WHERE recipient=? AND body=?').get(peer, original).n,
|
|
137
|
+
intents: db.prepare('SELECT COUNT(*) AS n FROM native_user_submissions WHERE submission_id=?').get(submissionId).n,
|
|
138
|
+
pendingEvents: db.prepare("SELECT COUNT(*) AS n FROM events WHERE type='native.web.submission.pending' AND json_extract(payload,'$.submission_id')=?").get(submissionId).n
|
|
139
|
+
}));
|
|
140
|
+
assert.deepEqual(counts, { messages: 1, intents: 1, pendingEvents: 1 });
|
|
141
|
+
await service.poll();
|
|
142
|
+
const active = await api('GET', '/workers/' + peer + '/state');
|
|
143
|
+
assert.equal(active.deliveries.length, 1); assert.equal(active.deliveries[0].submission_id, submissionId);
|
|
144
|
+
assert.equal(active.deliveries[0].message_id, message.message_id); assert.equal(active.deliveries[0].origin, 'user');
|
|
145
|
+
assert.equal(adapter.sent, 1); assert.equal(adapter.active.submissionId, submissionId);
|
|
146
|
+
await service.poll(); assert.equal(adapter.sent, 1, 'Repeated queue inspection must not dispatch the same message twice');
|
|
147
|
+
await shot(page, viewport.name + '-native-retry-reconciled');
|
|
148
|
+
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2), true);
|
|
149
|
+
assert.deepEqual(responses, [409, 200]);
|
|
150
|
+
receipt.scenarios.push({ viewport, peer, submissionId, messageId: message.message_id,
|
|
151
|
+
owner: originalState.owner, generation: originalState.generation,
|
|
152
|
+
requestCount: requests.length, responseStatuses: responses, expectedFailure: FAILURE_CODE,
|
|
153
|
+
originalBodySha256: hash(original), laterDraftPreserved: true, observerRetryBlocked: true,
|
|
154
|
+
messages: counts.messages, durableIntents: counts.intents, pendingEvents: counts.pendingEvents,
|
|
155
|
+
deliveries: active.deliveries.length, userOriginRetained: true, providerInputs: adapter.sent });
|
|
156
|
+
check('native retry ' + viewport.name + ': original durable submission repairs once; edited draft and observer fence survive');
|
|
157
|
+
} catch (error) {
|
|
158
|
+
failure = error;
|
|
159
|
+
receipt.failure = { viewport: viewport.name, peer, message: error.message, requestCount: requests.length,
|
|
160
|
+
responseStatuses: responses, injected: retryFault.injected.filter(value => value.peer === peer) };
|
|
161
|
+
try { await shot(page, viewport.name + '-native-retry-failure'); } catch { /* Preserve the original assertion. */ }
|
|
162
|
+
throw error;
|
|
163
|
+
} finally {
|
|
164
|
+
const cleanup = await Promise.allSettled([
|
|
165
|
+
observerContext?.close(), context.close(), workerStarted ? api('POST', '/close', { peer }) : undefined
|
|
166
|
+
]);
|
|
167
|
+
const errors = cleanup.filter(value => value.status === 'rejected');
|
|
168
|
+
if (errors.length) {
|
|
169
|
+
receipt.cleanupErrors = errors.map(value => value.reason?.message || String(value.reason));
|
|
170
|
+
if (!failure) throw errors[0].reason;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
retryFault.assertConsumed();
|
|
175
|
+
assert.equal(retryFault.injected.length, 2);
|
|
176
|
+
receipt.injections = [...retryFault.injected];
|
|
177
|
+
}
|