You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/25 19:54:32 UTC
(maka) 01/05: feat(tui): isolate plugin presenters and unify page state
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 8d035063e8c18eb9d223db977a7c2978a2d50682
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Sat Sep 26 00:54:21 2026 +0800
feat(tui): isolate plugin presenters and unify page state
Share shell boundaries, navigation and retained drafts across native and JavaScript views. Give each JS page an isolated execution lifetime while preserving authoritative backend receipts and exact resource ownership.
Add release latency, startup, fault and memory acceptance harnesses. Correct owner EOF handoff and keep loaded transcripts readable after page failure.
Generated-by: Codex
---
Cargo.lock | 1 +
crates/cli/Cargo.toml | 1 +
crates/cli/src/candidate.rs | 16 +-
.../cli/tests/fixtures/board-plugin/board-ui.mjs | 140 +++
crates/cli/tests/fixtures/board-plugin/host.mjs | 128 +--
.../{host.mjs => configured-ui.mjs} | 25 +-
.../cli/tests/fixtures/configured-plugin/host.mjs | 10 +-
.../cli/tests/fixtures/faults-plugin/faults-ui.mjs | 68 ++
crates/cli/tests/fixtures/faults-plugin/host.mjs | 99 ++
crates/cli/tests/fixtures/memory-plugin/host.mjs | 112 ++
.../host.mjs => memory-plugin/memory-ui.mjs} | 37 +-
.../fixtures/nested-navigation-plugin/host.mjs | 67 ++
.../nested-navigation-plugin/nested-ui.mjs | 44 +
.../tests/fixtures/workhub-notes-plugin/host.mjs | 48 +-
.../fixtures/workhub-notes-plugin/notes-ui.mjs | 52 +
crates/cli/tests/integration/candidate.rs | 2 +
crates/cli/tests/integration/candidate/startup.rs | 138 +++
crates/cli/tests/integration/tui.rs | 18 +-
crates/cli/tests/integration/tui/attachments.rs | 2 +-
crates/cli/tests/integration/tui/board.rs | 8 +-
crates/cli/tests/integration/tui/board/activity.rs | 2 +-
.../cli/tests/integration/tui/board/performance.rs | 288 +++++
.../integration/tui/board/performance/gated.mjs | 74 ++
.../integration/tui/board/performance/pending.rs | 212 ++++
.../tui/board/performance/pending/gate.rs | 150 +++
.../integration/tui/board/performance/proxy.rs | 220 ++++
.../tui/board/performance/proxy/fence.rs | 198 ++++
.../integration/tui/board/performance/report.rs | 253 +++++
.../integration/tui/board/performance/timed.rs | 219 ++++
.../integration/tui/board/performance/visual.rs | 193 ++++
crates/cli/tests/integration/tui/board/wire.rs | 17 +
crates/cli/tests/integration/tui/branch.rs | 2 +-
crates/cli/tests/integration/tui/builtins.rs | 11 +-
.../integration/tui/builtins/conversations.rs | 5 +-
crates/cli/tests/integration/tui/cold_startup.rs | 141 +++
.../tests/integration/tui/cold_startup/cases.rs | 194 ++++
.../integration/tui/cold_startup/lifecycle.rs | 194 ++++
.../cli/tests/integration/tui/cold_startup/page.rs | 113 ++
crates/cli/tests/integration/tui/connections.rs | 26 +-
crates/cli/tests/integration/tui/extensions.rs | 14 +-
.../cli/tests/integration/tui/fault_acceptance.rs | 70 ++
.../integration/tui/fault_acceptance/cases.rs | 289 +++++
.../integration/tui/fault_acceptance/fixture.rs | 209 ++++
.../integration/tui/fault_acceptance/measure.rs | 198 ++++
.../integration/tui/fault_acceptance/mutation.rs | 233 ++++
.../tui/fault_acceptance/mutation/applied.rs | 121 ++
.../cli/tests/integration/tui/memory_acceptance.rs | 260 +++++
.../integration/tui/memory_acceptance/capacity.rs | 229 ++++
.../integration/tui/memory_acceptance/case.rs | 287 +++++
.../integration/tui/memory_acceptance/report.rs | 189 ++++
.../integration/tui/memory_acceptance/sampler.rs | 144 +++
crates/cli/tests/integration/tui/navigation.rs | 5 +-
crates/cli/tests/integration/tui/plugins.rs | 19 +-
crates/cli/tests/integration/tui/reading.rs | 2 +-
crates/cli/tests/integration/tui/recovery.rs | 2 +-
crates/cli/tests/integration/tui/references.rs | 2 +-
crates/cli/tests/integration/tui/revision.rs | 2 +-
crates/cli/tests/integration/tui/skills.rs | 2 +-
crates/cli/tests/integration/tui/workhub.rs | 7 +-
.../tests/integration/tui/workhub/composition.rs | 7 +-
.../tests/integration/tui/workhub/navigation.rs | 174 +++
crates/cli/tests/integration/tui/workhub/setup.rs | 9 +-
crates/js-runtime/src/plugin.rs | 28 +-
crates/js-runtime/src/plugin/engine.rs | 62 +-
crates/js-runtime/src/plugin/presenter.js | 102 ++
crates/js-runtime/src/plugin/sdk.js | 185 +--
crates/js-runtime/src/plugin/terminal-builders.js | 114 ++
crates/js-runtime/src/plugin/worker.rs | 4 +-
crates/plugins/src/package/manifest.rs | 2 +-
crates/plugins/src/remote.rs | 10 +
crates/plugins/src/terminal_ui.rs | 3 +-
crates/plugins/src/terminal_ui/presenter.rs | 62 +
.../plugins/src/terminal_ui/transcript/resource.rs | 39 +-
crates/plugins/src/terminal_ui/view.rs | 164 ++-
crates/plugins/src/terminal_ui/view/boundary.rs | 241 ++++
crates/protocol/src/lib.rs | 2 +-
crates/runtime-host/src/plugins/javascript.rs | 16 +-
.../src/plugins/javascript/presenter.rs | 243 ++++
.../src/plugins/javascript/presenter/bridge.rs | 214 ++++
.../plugins/javascript/presenter/bridge/tests.rs | 129 +++
.../src/plugins/javascript/presenter/tests.rs | 203 ++++
.../src/plugins/javascript/registration.rs | 73 +-
.../plugins/javascript/registration/terminal.rs | 251 +++++
.../runtime-host/src/plugins/javascript/remote.rs | 5 +-
crates/runtime-host/src/server.rs | 1 +
crates/runtime-host/src/server/plugin_remote.rs | 112 +-
.../src/server/plugin_remote/registry.rs | 59 +-
.../src/server/plugin_remote/registry/binding.rs | 274 +++++
.../src/server/plugin_remote/registry/page.rs | 271 +++++
crates/runtime-host/src/server/startup.rs | 226 ++++
.../tests/fixtures/echo-ui.mjs} | 29 +-
crates/runtime-host/tests/fixtures/mutation-ui.mjs | 72 ++
.../runtime-host/tests/fixtures/presenter-host.mjs | 139 +++
.../runtime-host/tests/fixtures/presenter-ui.mjs | 76 ++
.../runtime-host/tests/fixtures/remote-plugin.mjs | 51 +-
.../tests/fixtures/terminal-mutation-fault.mjs | 113 ++
.../tests/fixtures/workflow/maka.extension.json | 2 +-
.../tests/integration/client_plugins.rs | 2 +-
.../tests/integration/javascript_plugins.rs | 2 +-
.../tests/integration/plugin_authorization.rs | 2 +-
.../tests/integration/plugin_remote.rs | 2 +
.../tests/integration/plugin_remote/javascript.rs | 47 +-
.../integration/plugin_remote/mutation_fault.rs | 252 +++++
.../tests/integration/plugin_remote/presenter.rs | 262 +++++
.../integration/plugin_remote/presenter/fixture.rs | 281 +++++
.../plugin_remote/presenter/installed.rs | 181 +++
.../plugin_remote/presenter/installed/fixture.rs | 112 ++
.../presenter/installed/observations.rs | 89 ++
.../plugin_remote/presenter/observations.rs | 267 +++++
.../integration/plugin_remote/presenter/sources.rs | 145 +++
crates/runtime-host/tests/plugins.rs | 2 +-
crates/runtime-host/tests/plugins/management.rs | 2 +-
crates/tui/locales/en.ftl | 11 +-
crates/tui/locales/zh-CN.ftl | 11 +-
crates/tui/locales/zh-TW.ftl | 11 +-
crates/tui/src/app.rs | 501 +++++----
crates/tui/src/apps.rs | 1184 ++++++++++++++++----
crates/tui/src/apps/admission.rs | 125 +++
crates/tui/src/apps/admission/review.rs | 99 ++
crates/tui/src/apps/admission/tests.rs | 145 +++
crates/tui/src/apps/drafts.rs | 14 +-
crates/tui/src/apps/drafts/admission.rs | 49 +
crates/tui/src/apps/instance.rs | 224 +++-
crates/tui/src/apps/io.rs | 231 +---
crates/tui/src/apps/io/document.rs | 232 ++++
crates/tui/src/apps/io/document/tests.rs | 145 +++
crates/tui/src/apps/io/document/tests/applied.rs | 225 ++++
crates/tui/src/apps/io/document/tests/retained.rs | 202 ++++
crates/tui/src/apps/io/transcript.rs | 12 +-
crates/tui/src/apps/io/transcript/pages.rs | 1 +
crates/tui/src/apps/io/transcript/runner.rs | 82 +-
crates/tui/src/apps/io/transcript/test_peer.rs | 22 +-
crates/tui/src/apps/io/transcript/transport.rs | 105 +-
crates/tui/src/apps/io/watches.rs | 243 ++++
crates/tui/src/apps/key.rs | 189 +++-
crates/tui/src/apps/mount.rs | 340 ++++++
crates/tui/src/apps/page.rs | 107 +-
crates/tui/src/apps/panels.rs | 66 +-
crates/tui/src/apps/region.rs | 37 +-
crates/tui/src/apps/saved.rs | 131 ++-
crates/tui/src/apps/saved/admission.rs | 85 ++
crates/tui/src/apps/saved/admission/tests.rs | 106 ++
crates/tui/src/apps/transcript.rs | 7 +-
crates/tui/src/apps/transcript/region.rs | 4 +-
crates/tui/src/apps/transcript/runtime.rs | 64 +-
crates/tui/src/apps/transcript/tests.rs | 6 +-
crates/tui/src/apps/transcript/tests/retained.rs | 176 +++
crates/tui/src/apps/tree.rs | 47 +-
crates/tui/src/chrome.rs | 10 +-
crates/tui/src/editor.rs | 1 +
crates/tui/src/editor/preview.rs | 143 +++
crates/tui/src/lib.rs | 28 +-
crates/tui/src/navigation.rs | 81 +-
crates/tui/src/navigation/location.rs | 312 ++++++
crates/tui/src/navigation/state.rs | 97 +-
crates/tui/src/navigation/state/admission.rs | 110 ++
crates/tui/src/pages/attachments/view.rs | 68 +-
crates/tui/src/pages/chat/history.rs | 2 +-
crates/tui/src/pages/chat/search.rs | 26 +-
crates/tui/src/pages/commands.rs | 21 +
crates/tui/src/pages/manage/connection.rs | 111 +-
crates/tui/src/pages/manage/oauth/view.rs | 9 +-
crates/tui/src/pages/onboarding/view.rs | 9 +-
crates/tui/src/pages/plugins/tests.rs | 2 +-
crates/tui/src/pages/queue.rs | 192 +++-
crates/tui/src/pages/queue/input.rs | 73 +-
crates/tui/src/pages/references.rs | 33 +-
crates/tui/src/pages/sending.rs | 14 +-
crates/tui/src/pages/sessions.rs | 2 +-
crates/tui/src/pages/settings.rs | 150 ++-
crates/tui/src/pages/sidebar.rs | 8 +-
crates/tui/src/pages/skills/view.rs | 32 +-
crates/tui/src/state.rs | 2 +-
crates/tui/src/state/snapshot.rs | 90 +-
crates/tui/src/state/snapshot/admission.rs | 87 ++
crates/tui/src/state/snapshot/admission/pages.rs | 113 ++
crates/tui/src/state/snapshot/admission/tests.rs | 127 +++
crates/tui/src/state/store.rs | 40 +-
crates/tui/src/ui.rs | 4 +-
crates/tui/src/ui/boundary.rs | 129 +++
crates/tui/src/ui/layout.rs | 111 +-
crates/tui/src/ui/node.rs | 60 +
crates/tui/src/ui/surface.rs | 401 ++++++-
crates/tui/src/ui/surface/reader.rs | 39 +-
crates/tui/src/ui/transcript.rs | 40 +-
crates/tui/src/ui/transcript/navigation.rs | 2 +-
crates/tui/src/ui/transcript/performance.rs | 261 +++++
crates/tui/src/ui/transcript/scrollbar.rs | 61 +
crates/tui/src/ui/transcript/search.rs | 84 +-
crates/tui/src/ui/transcript/search/bar.rs | 210 ++++
crates/tui/src/view.rs | 505 +++------
crates/tui/src/view/queue.rs | 178 +--
crates/tui/src/view/session.rs | 550 ++++-----
crates/tui/src/view/session/composer.rs | 288 +++++
crates/tui/src/view/session/feedback.rs | 8 +-
crates/tui/src/view/session/search.rs | 99 +-
crates/tui/src/view/session/search/history.rs | 167 ++-
crates/tui/src/view/shell.rs | 182 +++
crates/tui/src/view/shell/controls.rs | 174 +++
crates/tui/src/view/shell/input.rs | 177 +++
crates/workhub/src/plugin/terminal.rs | 43 +-
packages/plugin-sdk/README.md | 22 +-
packages/plugin-sdk/README.zh-CN.md | 22 +-
packages/plugin-sdk/src/host.ts | 8 +-
packages/plugin-sdk/src/terminal-app.ts | 73 ++
packages/plugin-sdk/src/terminal-transcript.ts | 8 +-
packages/plugin-sdk/src/terminal-view.ts | 54 +-
.../tests/fixtures/terminal-boundary.json | 29 +
.../plugin-sdk/tests/terminal-boundary.test.mjs | 102 ++
.../plugin-sdk/tests/terminal-boundary.types.ts | 38 +
packages/plugin-sdk/tests/terminal-mount.test.mjs | 84 ++
.../tests/terminal-presenter-harness.mjs | 63 ++
.../plugin-sdk/tests/terminal-presenter.test.mjs | 191 ++++
.../plugin-sdk/tests/terminal-presenter.types.ts | 68 ++
.../tests/terminal-transcript-harness.mjs | 12 +-
.../plugin-sdk/tests/terminal-transcript.test.mjs | 45 +-
packages/runtime-host/src/protocol/index.ts | 2 +-
217 files changed, 20259 insertions(+), 2794 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index dd4fe4363d..2c36b9a8b0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4917,6 +4917,7 @@ dependencies = [
name = "maka-cli"
version = "0.2.0"
dependencies = [
+ "alacritty_terminal",
"base64 0.22.1",
"clap",
"flate2",
diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml
index a0c0116663..b01fec6ebc 100644
--- a/crates/cli/Cargo.toml
+++ b/crates/cli/Cargo.toml
@@ -65,6 +65,7 @@ uuid.workspace = true
tempfile.workspace = true
[dev-dependencies]
+alacritty_terminal = { version = "0.26.0", default-features = false }
maka-plugins.workspace = true
unicode-width.workspace = true
libc.workspace = true
diff --git a/crates/cli/src/candidate.rs b/crates/cli/src/candidate.rs
index d24e863abd..9978157fd5 100644
--- a/crates/cli/src/candidate.rs
+++ b/crates/cli/src/candidate.rs
@@ -72,8 +72,9 @@ impl Candidate {
};
let cancel = CancellationToken::new();
let _cancel_on_exit = cancel.clone().drop_guard();
+ let owner_lost = CancellationToken::new();
if self.owner_stdin {
- watch_owner(cancel.clone())?;
+ watch_owner(owner_lost.clone())?;
}
let signals = super::signals::watch(cancel.clone())?;
let host = Host::open_with_options(
@@ -119,7 +120,18 @@ impl Candidate {
.await;
idle_cancel.cancel();
});
- let result = super::serve::listen(endpoint, websocket, host, cancel).await;
+ let serving = super::serve::listen(endpoint, websocket, host.clone(), cancel);
+ tokio::pin!(serving);
+ let result = tokio::select! {
+ biased;
+ _ = owner_lost.cancelled() => {
+ // EOF abandons an unaccepted startup. Once a client was admitted,
+ // ordinary Host residency and idle expiry own this lifetime.
+ host.abandon_unaccepted_startup();
+ serving.await
+ }
+ result = &mut serving => result,
+ };
expiry.abort();
let _ = expiry.await;
drop(signals);
diff --git a/crates/cli/tests/fixtures/board-plugin/board-ui.mjs b/crates/cli/tests/fixtures/board-plugin/board-ui.mjs
new file mode 100644
index 0000000000..9922d6839a
--- /dev/null
+++ b/crates/cli/tests/fixtures/board-plugin/board-ui.mjs
@@ -0,0 +1,140 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/** @typedef {'todo' | 'doing' | 'done'} Column */
+/** @typedef {{id: string, title: string, column: Column, note: string}} Card */
+/** @typedef {{revision: number | null, cards: Card[], activity: import('../../../../../packages/plugin-sdk/src/host.js').TranscriptResource | null}} Model */
+
+/** @type {import('../../../../../packages/plugin-sdk/src/host.js').TerminalPageFactory<Model>} */
+export default ({ tui }) => {
+ const columns = /** @type {const} */ (['todo', 'doing', 'done']);
+ /** @type {Record<Column, [string, string, string]>} */
+ const titles = {
+ todo: ['To do', '待办', '待辦'],
+ doing: ['Doing', '进行中', '進行中'],
+ done: ['Done', '完成', '完成'],
+ };
+ return {
+ async read(route, cx) {
+ const model = await cx.backend();
+ if (model.activity) {
+ return {
+ title: cx.t('Board activity', '看板活动', '看板活動'),
+ revision: 'activity',
+ root: tui.column('root', [
+ tui.text(
+ 'intro',
+ cx.t('Live board activity and history', '看板实时活动与历史', '看板即時活動與歷史'),
+ 'muted',
+ ),
+ tui.transcript('activity', model.activity),
+ ]),
+ };
+ }
+ const { revision, cards } = model;
+ const stamp = String(revision ?? 0);
+ const card =
+ route &&
+ typeof route === 'object' &&
+ 'card' in route &&
+ cards.find((item) => item.id === route.card);
+ if (card) {
+ const index = columns.indexOf(card.column);
+ const actions = [
+ tui.action('save', cx.t('Save', '保存', '儲存'), { fields: ['title', 'note'] }),
+ tui.action('delete', cx.t('Delete', '删除', '刪除'), {
+ confirm: {
+ title: cx.t('Delete this card?', '删除这张卡片?', '刪除這張卡片?'),
+ message: cx.t(
+ 'It leaves the board for good.',
+ '它会从看板上永久移除。',
+ '它會從看板上永久移除。',
+ ),
+ destructive: true,
+ },
+ }),
+ ];
+ const buttons = [tui.button('save', 'save', 'primary')];
+ if (index > 0) {
+ actions.push(tui.action('left', cx.t('Move back', '后退一列', '後退一欄')));
+ buttons.push(tui.button('left', 'left'));
+ }
+ if (index < 2) {
+ actions.push(tui.action('right', cx.t('Move on', '前进一列', '前進一欄')));
+ buttons.push(tui.button('right', 'right'));
+ }
+ buttons.push(tui.button('delete', 'delete', 'destructive'));
+ return {
+ title: card.title,
+ revision: stamp,
+ fields: [tui.line('title', card.title, 200), tui.area('note', card.note, 4000)],
+ actions,
+ root: tui.column('root', [
+ tui.text('column', cx.t(...titles[card.column]), 'accent'),
+ tui.stack('form', [
+ tui.input('title', 'title', cx.t('Title', '标题', '標題')),
+ tui.input('note', 'note', cx.t('Note', '备注', '備註')),
+ ]),
+ tui.row('controls', buttons),
+ ]),
+ };
+ }
+ const lanes = columns.map((column) =>
+ tui.column(
+ column,
+ [
+ tui.spans('title', [
+ [cx.t(...titles[column]), 'strong'],
+ [` ${cards.filter((item) => item.column === column).length}`, 'subtle'],
+ ]),
+ ...cards
+ .filter((item) => item.column === column)
+ .map((item) =>
+ tui.link(
+ `card-${item.id}`,
+ item.title,
+ { card: item.id },
+ item.note ? { detail: item.note } : {},
+ ),
+ ),
+ ],
+ 0,
+ ),
+ );
+ return {
+ title: cx.t('Board', '看板', '看板'),
+ revision: stamp,
+ fields: [tui.line('new', '', 200, { placeholder: cx.t('A new card', '新卡片', '新卡片') })],
+ actions: [tui.action('add', cx.t('Add', '添加', '新增'), { fields: ['new'] })],
+ root: tui.column('root', [
+ tui.row('lanes', lanes),
+ tui.rule('divider'),
+ tui.row('adding', [
+ tui.input('new', 'new', cx.t('Card', '卡片', '卡片')),
+ tui.button('add', 'add', 'primary'),
+ ]),
+ tui.link('activity', cx.t('Board activity', '看板活动', '看板活動'), {
+ activity: true,
+ }),
+ ]),
+ };
+ },
+ submit: (_submission, cx) => cx.backend(),
+ };
+};
diff --git a/crates/cli/tests/fixtures/board-plugin/host.mjs b/crates/cli/tests/fixtures/board-plugin/host.mjs
index c17acdb1e0..2cbbb2697c 100644
--- a/crates/cli/tests/fixtures/board-plugin/host.mjs
+++ b/crates/cli/tests/fixtures/board-plugin/host.mjs
@@ -28,12 +28,6 @@
export default async function activate(ctx) {
const { tui } = ctx;
const columns = /** @type {const} */ (['todo', 'doing', 'done']);
- /** @type {Record<Column, [string, string, string]>} */
- const titles = {
- todo: ['To do', '待办', '待辦'],
- doing: ['Doing', '进行中', '進行中'],
- done: ['Done', '完成', '完成'],
- };
/** @returns {Promise<{revision: number | null, cards: Card[]}>} */
const load = async () => {
const record = await ctx.storage.read('cards');
@@ -114,114 +108,24 @@ export default async function activate(ctx) {
await tui.app(
'board',
{
- async read(route, cx) {
- viewReads++;
- if (route && typeof route === 'object' && 'activity' in route && route.activity === true) {
- activityViewReads++;
- return {
- title: cx.t('Board activity', '看板活动', '看板活動'),
- revision: 'activity',
- root: tui.column('root', [
- tui.text(
- 'intro',
- cx.t('Live board activity and history', '看板实时活动与历史', '看板即時活動與歷史'),
- 'muted',
- ),
- tui.transcript('activity', activity.resource),
- ]),
- };
- }
- const { revision, cards } = await load();
- const stamp = String(revision ?? 0);
- const card =
- route &&
- typeof route === 'object' &&
- 'card' in route &&
- cards.find((item) => item.id === route.card);
- if (card) {
- const index = columns.indexOf(card.column);
- const actions = [
- tui.action('save', cx.t('Save', '保存', '儲存'), { fields: ['title', 'note'] }),
- tui.action('delete', cx.t('Delete', '删除', '刪除'), {
- confirm: {
- title: cx.t('Delete this card?', '删除这张卡片?', '刪除這張卡片?'),
- message: cx.t(
- 'It leaves the board for good.',
- '它会从看板上永久移除。',
- '它會從看板上永久移除。',
- ),
- destructive: true,
- },
- }),
- ];
- const buttons = [tui.button('save', 'save', 'primary')];
- if (index > 0) {
- actions.push(tui.action('left', cx.t('Move back', '后退一列', '後退一欄')));
- buttons.push(tui.button('left', 'left'));
- }
- if (index < 2) {
- actions.push(tui.action('right', cx.t('Move on', '前进一列', '前進一欄')));
- buttons.push(tui.button('right', 'right'));
+ entry: 'board-ui.mjs',
+ resources: [activity.resource],
+ async backend(submission, cx) {
+ if (submission.kind === 'read') {
+ viewReads++;
+ const route = submission.route;
+ if (
+ route &&
+ typeof route === 'object' &&
+ 'activity' in route &&
+ route.activity === true
+ ) {
+ activityViewReads++;
+ return { revision: null, cards: [], activity: activity.resource };
}
- buttons.push(tui.button('delete', 'delete', 'destructive'));
- return {
- title: card.title,
- revision: stamp,
- fields: [tui.line('title', card.title, 200), tui.area('note', card.note, 4000)],
- actions,
- root: tui.column('root', [
- tui.text('column', cx.t(...titles[card.column]), 'accent'),
- tui.stack('form', [
- tui.input('title', 'title', cx.t('Title', '标题', '標題')),
- tui.input('note', 'note', cx.t('Note', '备注', '備註')),
- ]),
- tui.row('controls', buttons),
- ]),
- };
+ return { ...(await load()), activity: null };
}
- const lanes = columns.map((column) =>
- tui.column(
- column,
- [
- tui.spans('title', [
- [cx.t(...titles[column]), 'strong'],
- [` ${cards.filter((item) => item.column === column).length}`, 'subtle'],
- ]),
- ...cards
- .filter((item) => item.column === column)
- .map((item) =>
- tui.link(
- `card-${item.id}`,
- item.title,
- { card: item.id },
- item.note ? { detail: item.note } : {},
- ),
- ),
- ],
- 0,
- ),
- );
- return {
- title: cx.t('Board', '看板', '看板'),
- revision: stamp,
- fields: [
- tui.line('new', '', 200, { placeholder: cx.t('A new card', '新卡片', '新卡片') }),
- ],
- actions: [tui.action('add', cx.t('Add', '添加', '新增'), { fields: ['new'] })],
- root: tui.column('root', [
- tui.row('lanes', lanes),
- tui.rule('divider'),
- tui.row('adding', [
- tui.input('new', 'new', cx.t('Card', '卡片', '卡片')),
- tui.button('add', 'add', 'primary'),
- ]),
- tui.link('activity', cx.t('Board activity', '看板活动', '看板活動'), {
- activity: true,
- }),
- ]),
- };
- },
- async submit(submission, cx) {
+ if (submission.kind !== 'submit') return { kind: 'unrecorded' };
const { revision, cards } = await load();
if (String(revision ?? 0) !== submission.revision) return { kind: 'conflict' };
const route = submission.route;
diff --git a/crates/cli/tests/fixtures/configured-plugin/host.mjs b/crates/cli/tests/fixtures/configured-plugin/configured-ui.mjs
similarity index 57%
copy from crates/cli/tests/fixtures/configured-plugin/host.mjs
copy to crates/cli/tests/fixtures/configured-plugin/configured-ui.mjs
index 7009a20841..b43f39b892 100644
--- a/crates/cli/tests/fixtures/configured-plugin/host.mjs
+++ b/crates/cli/tests/fixtures/configured-plugin/configured-ui.mjs
@@ -17,21 +17,10 @@
* under the License.
*/
-// Independent fixture for opaque configuration lifecycle; Board and WorkHub
-// continue to exercise their own unchanged domain behavior.
-export default async function activate(ctx, config) {
- if (typeof config?.greeting !== 'string') throw new Error('greeting must be a string');
- await ctx.tui.app(
- 'greeting',
- {
- async read() {
- return {
- title: 'Greeting',
- revision: '1',
- root: ctx.tui.text('greeting', config.greeting),
- };
- },
- },
- { title: { fallback: 'Greeting', translations: {} }, context: 'application' },
- );
-}
+export default ({ tui }) => ({
+ async read(_route, cx) {
+ const { greeting } = await cx.backend();
+ return { title: 'Greeting', revision: '1', root: tui.text('greeting', greeting) };
+ },
+ submit: (_submission, cx) => cx.backend(),
+});
diff --git a/crates/cli/tests/fixtures/configured-plugin/host.mjs b/crates/cli/tests/fixtures/configured-plugin/host.mjs
index 7009a20841..3fff7c7c0d 100644
--- a/crates/cli/tests/fixtures/configured-plugin/host.mjs
+++ b/crates/cli/tests/fixtures/configured-plugin/host.mjs
@@ -24,12 +24,10 @@ export default async function activate(ctx, config) {
await ctx.tui.app(
'greeting',
{
- async read() {
- return {
- title: 'Greeting',
- revision: '1',
- root: ctx.tui.text('greeting', config.greeting),
- };
+ entry: 'configured-ui.mjs',
+ async backend(request) {
+ if (request.kind === 'read') return { greeting: config.greeting };
+ return { kind: 'rejected', message: 'Greeting is read-only' };
},
},
{ title: { fallback: 'Greeting', translations: {} }, context: 'application' },
diff --git a/crates/cli/tests/fixtures/faults-plugin/faults-ui.mjs b/crates/cli/tests/fixtures/faults-plugin/faults-ui.mjs
new file mode 100644
index 0000000000..1ed4ce4397
--- /dev/null
+++ b/crates/cli/tests/fixtures/faults-plugin/faults-ui.mjs
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export default ({ tui }) => ({
+ async read(_route, cx) {
+ const model = await cx.backend();
+ const { mode, title } = model;
+ // The backend marker proves this bridge was called, not the loop start.
+ if (mode === 'await_then_runaway_read') await Promise.resolve();
+ if (mode === 'runaway_read' || mode === 'await_then_runaway_read') {
+ while (true) {
+ /* Only this document's presenter VM is occupied. */
+ }
+ }
+ if (mode === 'never_settle') await new Promise(() => {});
+ if (mode === 'cooperative_pending') {
+ await cx.signal.wait();
+ throw Object.assign(new Error('Acceptance read cancelled'), { code: 'cancelled' });
+ }
+ const body = {
+ title,
+ revision: '1',
+ fields: [tui.line('draft', '', 128, { placeholder: 'Local draft' })],
+ actions: [tui.action('run', 'Run presenter')],
+ root: tui.boundary(
+ 'root',
+ tui.column('body', [
+ tui.text('retained', 'Last-good acceptance content'),
+ tui.input('draft', 'draft', 'Draft'),
+ tui.transcript('history', model.history),
+ ]),
+ {
+ bottom: tui.button('run', 'run'),
+ },
+ ),
+ };
+ if (mode === 'invalid') body.root = { kind: 'not_a_node', key: 'bad' };
+ if (mode === 'duplicate')
+ body.root = tui.column('bad', [tui.text('same', 'first'), tui.text('same', 'second')]);
+ if (mode === 'oversized') body.root = tui.text('large', 'x'.repeat(80 * 1024));
+ return body;
+ },
+ async submit(submission, cx) {
+ const receipt = await cx.backend();
+ if (submission.action === 'run') {
+ while (true) {
+ /* The backend's rejection remains authoritative after this UI fault. */
+ }
+ }
+ return receipt;
+ },
+});
diff --git a/crates/cli/tests/fixtures/faults-plugin/host.mjs b/crates/cli/tests/fixtures/faults-plugin/host.mjs
new file mode 100644
index 0000000000..c5cb3d4349
--- /dev/null
+++ b/crates/cli/tests/fixtures/faults-plugin/host.mjs
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// The test replaces only this label when installing independent packages.
+const title = '__ACCEPTANCE_TITLE__';
+
+export default async function activate(ctx) {
+ const { tui } = ctx;
+ let mode = 'good';
+ let reads = 0;
+ let entered = 0;
+ // UI cancellation is observed through document/source cleanup, not a UI write.
+ const cancelled = 0;
+ let submissions = 0;
+ let revision = 1;
+ let arm = 0;
+ let marked = false;
+ const markEntered = async (callback) => {
+ if (marked) return;
+ marked = true;
+ await ctx.storage.batch([
+ {
+ key: `acceptance/entered/${arm}`,
+ expectedRevision: null,
+ data: { kind: 'present', value: { arm, mode, callback } },
+ },
+ ]);
+ };
+ const key = { turn: 'retained', message: 'history', part: 'text' };
+ const content = Array.from(
+ { length: 60 },
+ (_, index) => `Retained record ${String(index).padStart(3, '0')} — 中文 🚀`,
+ ).join(' \n');
+ const transcript = await tui.transcriptResource('history', {
+ blocks: [{ key, revision: '1', kind: 'assistant', content: { text: content } }],
+ });
+ ctx.effect(() => transcript.close());
+ const changed = await tui.changes('changed');
+ const stats = () => ({ arm, mode, reads, entered, cancelled, submissions, ...transcript.stats });
+ await ctx.remote.method('ping', () => ({ title, alive: true }));
+ await ctx.remote.method('stats', stats);
+ await ctx.remote.method('control', async (input) => {
+ arm++;
+ marked = false;
+ mode = String(input.mode);
+ if (mode === 'overflow') {
+ await markEntered('control');
+ // One synchronous burst prevents a slow consumer from draining the bounded
+ // SDK queue. Replacing one small block keeps producer storage bounded.
+ for (let index = 0; index < 9000; index++) {
+ transcript.replace({
+ key,
+ revision: String(++revision),
+ kind: 'assistant',
+ content: { text: `${content}\nBurst ${index}` },
+ });
+ }
+ } else if (input.refresh) changed();
+ return stats();
+ });
+ await tui.app(
+ 'panel',
+ {
+ entry: 'faults-ui.mjs',
+ resources: [transcript.resource],
+ async backend(request) {
+ if (request.kind === 'read') {
+ reads++;
+ if (!['good', 'runaway_submit', 'overflow'].includes(mode)) {
+ entered++;
+ await markEntered('read');
+ }
+ return { title, mode, history: transcript.resource };
+ }
+ if (request.kind !== 'submit') return { kind: 'unrecorded' };
+ submissions++;
+ await markEntered('submit');
+ return { kind: 'rejected', message: 'Acceptance action has no writes' };
+ },
+ },
+ { title: { fallback: title, translations: {} }, context: 'application', changes: 'changed' },
+ );
+}
diff --git a/crates/cli/tests/fixtures/memory-plugin/host.mjs b/crates/cli/tests/fixtures/memory-plugin/host.mjs
new file mode 100644
index 0000000000..6ee1208edc
--- /dev/null
+++ b/crates/cli/tests/fixtures/memory-plugin/host.mjs
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/** @param {import('../../../../../packages/plugin-sdk/src/host.js').HostContext} ctx */
+export default async function activate(ctx) {
+ const encoder = new TextEncoder();
+ const previous = await ctx.storage.read('activations');
+ const activations = Number(previous?.data.kind === 'present' ? previous.data.value : 0) + 1;
+ await ctx.storage.batch([
+ {
+ key: 'activations',
+ expectedRevision: previous?.revision ?? null,
+ data: { kind: 'present', value: activations },
+ },
+ ]);
+ /** @type {import('../../../../../packages/plugin-sdk/src/host.js').TranscriptBlock[]} */
+ const blocks = Array.from({ length: 1000 }, (_, index) => {
+ const id = String(index).padStart(4, '0');
+ const lines = [
+ `Memory block ${id} — 中文🦀`,
+ 'Unicode **progress**: 字符与字节分别计数。',
+ 'A bounded paragraph for the actual reader.',
+ 'Another logical line; terminal wrapping is a different count.',
+ '```rust',
+ `let block = ${index};`,
+ '```',
+ 'The source retains Markdown and Unicode.',
+ index === 999 ? '```text' : 'A closed fence precedes this paragraph.',
+ `Memory end ${id}`,
+ ];
+ return {
+ key: { turn: 'memory', message: id, part: 'text' },
+ revision: '0',
+ kind: 'assistant',
+ content: { text: lines.join('\n') },
+ };
+ });
+ const initialLogicalLines = blocks.reduce(
+ (total, block) => total + block.content.text.split('\n').length,
+ 0,
+ );
+ const initialSourceBytes = encoder.encode(JSON.stringify(blocks)).length;
+ const source = await ctx.tui.transcriptResource('memory-lines', { blocks });
+ ctx.effect(() => source.close());
+ let viewReads = 0;
+ let appends = 0;
+ let appendedBytes = 0;
+ let appendedLines = 0;
+ await ctx.remote.method('memory-append', (input) => {
+ if (
+ typeof input !== 'number' ||
+ !Number.isSafeInteger(input) ||
+ input !== appends ||
+ appends >= 300
+ ) {
+ throw new Error('Expected the next bounded append index');
+ }
+ const marker = `Memory update ${String(appends).padStart(3, '0')}`;
+ const text = `\n${marker} — 中文🦀${appends === 299 ? '\n```\nStream complete.' : ''}`;
+ source.append({ turn: 'memory', message: '0999', part: 'text' }, text, String(++appends));
+ appendedBytes += encoder.encode(text).length;
+ appendedLines += text.split('\n').length - 1;
+ return { marker, bytes: encoder.encode(text).length };
+ });
+ await ctx.remote.method('memory-stats', () => ({
+ ...source.stats,
+ activations,
+ viewReads,
+ appends,
+ appendedBytes,
+ sourceBlocks: blocks.length,
+ initialLogicalLines,
+ initialSourceBytes,
+ currentLogicalLines: initialLogicalLines + appendedLines,
+ }));
+ await ctx.tui.app(
+ 'memory',
+ {
+ entry: 'memory-ui.mjs',
+ resources: [source.resource],
+ async backend(input) {
+ if (input.kind !== 'read') throw new Error('Memory page is read only');
+ viewReads++;
+ return { resource: source.resource };
+ },
+ },
+ {
+ title: {
+ fallback: 'Memory lab',
+ translations: { 'zh-CN': '内存测量', 'zh-TW': '記憶體測量' },
+ },
+ context: 'application',
+ icon: { glyph: '▤', ascii: 'M' },
+ },
+ );
+}
diff --git a/crates/cli/tests/fixtures/configured-plugin/host.mjs b/crates/cli/tests/fixtures/memory-plugin/memory-ui.mjs
similarity index 54%
copy from crates/cli/tests/fixtures/configured-plugin/host.mjs
copy to crates/cli/tests/fixtures/memory-plugin/memory-ui.mjs
index 7009a20841..fe559ce42a 100644
--- a/crates/cli/tests/fixtures/configured-plugin/host.mjs
+++ b/crates/cli/tests/fixtures/memory-plugin/memory-ui.mjs
@@ -17,21 +17,22 @@
* under the License.
*/
-// Independent fixture for opaque configuration lifecycle; Board and WorkHub
-// continue to exercise their own unchanged domain behavior.
-export default async function activate(ctx, config) {
- if (typeof config?.greeting !== 'string') throw new Error('greeting must be a string');
- await ctx.tui.app(
- 'greeting',
- {
- async read() {
- return {
- title: 'Greeting',
- revision: '1',
- root: ctx.tui.text('greeting', config.greeting),
- };
- },
- },
- { title: { fallback: 'Greeting', translations: {} }, context: 'application' },
- );
-}
+/** @type {import('../../../../../packages/plugin-sdk/src/host.js').TerminalPageFactory<{resource: import('../../../../../packages/plugin-sdk/src/host.js').TranscriptResource}>} */
+export default ({ tui }) => ({
+ async read(_route, cx) {
+ const model = await cx.backend();
+ return {
+ title: cx.t('Memory lab', '内存测量', '記憶體測量'),
+ revision: '1',
+ root: tui.column('root', [
+ tui.text(
+ 'intro',
+ cx.t('10,000 source lines', '10,000 源文本行', '10,000 原始文字行'),
+ 'muted',
+ ),
+ tui.transcript('reader', model.resource),
+ ]),
+ };
+ },
+ submit: (_submission, cx) => cx.backend(),
+});
diff --git a/crates/cli/tests/fixtures/nested-navigation-plugin/host.mjs b/crates/cli/tests/fixtures/nested-navigation-plugin/host.mjs
new file mode 100644
index 0000000000..437f2cd6d2
--- /dev/null
+++ b/crates/cli/tests/fixtures/nested-navigation-plugin/host.mjs
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export default async function activate(ctx) {
+ const { tui } = ctx;
+ let writes = 0;
+ await ctx.remote.method('writes', () => writes);
+ const source = (route) => {
+ const value = route?.pane === 'details' ? route.context : route;
+ if (
+ typeof value?.assignmentId !== 'string' ||
+ typeof value?.sourceSessionId !== 'string' ||
+ Object.keys(value).length !== 2
+ )
+ throw new Error('Expected immutable WorkHub task context');
+ return value;
+ };
+ const storageKey = (route) => `notes/${source(route).assignmentId}/${route.pane ?? 'base'}`;
+ await tui.app(
+ 'nested-notes',
+ {
+ entry: 'nested-ui.mjs',
+ async backend(submission) {
+ if (submission.kind === 'read') {
+ const context = source(submission.route);
+ const record = await ctx.storage.read(storageKey(submission.route));
+ const note = record?.data.kind === 'present' ? record.data.value : '';
+ return { context, note, revision: record?.revision ?? null };
+ }
+ if (submission.kind !== 'submit') return { kind: 'unrecorded' };
+ const key = storageKey(submission.route);
+ const record = await ctx.storage.read(key);
+ if (submission.revision !== String(record?.revision ?? 0)) return { kind: 'conflict' };
+ await ctx.storage.batch([
+ {
+ key,
+ expectedRevision: record?.revision ?? null,
+ data: { kind: 'present', value: submission.fields.note },
+ },
+ ]);
+ writes++;
+ return { kind: 'applied', route: submission.route };
+ },
+ },
+ {
+ title: { fallback: 'Nested notes' },
+ context: 'application',
+ placement: { kind: 'slot', name: 'workhub.task.detail' },
+ },
+ );
+}
diff --git a/crates/cli/tests/fixtures/nested-navigation-plugin/nested-ui.mjs b/crates/cli/tests/fixtures/nested-navigation-plugin/nested-ui.mjs
new file mode 100644
index 0000000000..1174b9f5db
--- /dev/null
+++ b/crates/cli/tests/fixtures/nested-navigation-plugin/nested-ui.mjs
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export default ({ tui }) => ({
+ async read(route, cx) {
+ const model = await cx.backend();
+ const { context, note } = model;
+ return {
+ title: 'Nested notes',
+ revision: String(model.revision ?? 0),
+ fields: [tui.line('note', note, 200)],
+ actions: [tui.action('save', 'Save nested note', { fields: ['note'] })],
+ root: tui.column('root', [
+ tui.text('stored', `Stored: ${note}`),
+ tui.input(
+ 'note',
+ 'note',
+ route.pane === 'details' ? 'Detail note draft' : 'Base note draft',
+ ),
+ tui.button('save', 'save', 'primary'),
+ ...(route.pane === 'details'
+ ? []
+ : [tui.link('details', 'Note details', { context, pane: 'details' })]),
+ ]),
+ };
+ },
+ submit: (_submission, cx) => cx.backend(),
+});
diff --git a/crates/cli/tests/fixtures/workhub-notes-plugin/host.mjs b/crates/cli/tests/fixtures/workhub-notes-plugin/host.mjs
index b9a9ac3159..4d8fc69318 100644
--- a/crates/cli/tests/fixtures/workhub-notes-plugin/host.mjs
+++ b/crates/cli/tests/fixtures/workhub-notes-plugin/host.mjs
@@ -22,6 +22,7 @@
export default async function activate(ctx) {
const { tui } = ctx;
const contexts = new Map();
+ const reviewing = new Set();
let reads = 0;
let liveStreams = 0;
// A public stream counts the actual observer owned by this
@@ -99,41 +100,30 @@ export default async function activate(ctx) {
);
});
await ctx.remote.method('review', async ({ assignmentId }) => {
- const record = await load(assignmentId);
- record.value.reviewed = true;
- await store(assignmentId, record);
+ reviewing.add(assignmentId);
+ notify();
+ try {
+ const record = await load(assignmentId);
+ record.value.reviewed = true;
+ await store(assignmentId, record);
+ } finally {
+ reviewing.delete(assignmentId);
+ notify();
+ }
return null;
});
await tui.app(
'notes-view',
{
- async read(route, cx) {
- const id = task(route);
- contexts.set(id, route);
- reads++;
- const record = await load(id);
- const note = record.value.note ?? '';
- return {
- title: cx.t('Task notes', '任务备注', '任務備註'),
- revision: String(record.revision ?? 0),
- fields: [tui.line('note', note, 200)],
- actions: [
- tui.action('save', cx.t('Save note', '保存备注', '儲存備註'), { fields: ['note'] }),
- ],
- root: tui.stack('root', [
- tui.text(
- 'review',
- record.value.reviewed ? 'Reviewed independently' : 'Not reviewed',
- 'subtle',
- ),
- tui.text('saved', `Saved note: ${note}`, 'subtle'),
- tui.input('note', 'note', cx.t('Note draft', '备注草稿', '備註草稿')),
- tui.row('controls', [tui.button('save', 'save', 'primary')]),
- ]),
- };
- },
- async submit(submission) {
+ entry: 'notes-ui.mjs',
+ async backend(submission) {
const id = task(submission.route);
+ if (submission.kind === 'read') {
+ contexts.set(id, submission.route);
+ reads++;
+ return { ...(await load(id)), busy: reviewing.has(id) };
+ }
+ if (submission.kind !== 'submit') return { kind: 'unrecorded' };
const record = await load(id);
if (submission.revision !== String(record.revision ?? 0)) return { kind: 'conflict' };
record.value.note = submission.fields.note;
diff --git a/crates/cli/tests/fixtures/workhub-notes-plugin/notes-ui.mjs b/crates/cli/tests/fixtures/workhub-notes-plugin/notes-ui.mjs
new file mode 100644
index 0000000000..d99acfec0e
--- /dev/null
+++ b/crates/cli/tests/fixtures/workhub-notes-plugin/notes-ui.mjs
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export default ({ tui }) => ({
+ async read(_route, cx) {
+ const model = await cx.backend();
+ const note = model.value.note ?? '';
+ return {
+ title: cx.t('Task notes', '任务备注', '任務備註'),
+ revision: String(model.revision ?? 0),
+ fields: [tui.line('note', note, 200)],
+ actions: [
+ tui.action('save', cx.t('Save note', '保存备注', '儲存備註'), { fields: ['note'] }),
+ ],
+ root: tui.boundary(
+ 'root',
+ tui.stack('fields', [
+ tui.text(
+ 'review',
+ model.value.reviewed ? 'Reviewed independently' : 'Not reviewed',
+ 'subtle',
+ ),
+ tui.text('saved', `Saved note: ${note}`, 'subtle'),
+ tui.input('note', 'note', cx.t('Note draft', '备注草稿', '備註草稿')),
+ ]),
+ {
+ bottom: tui.row('controls', [tui.button('save', 'save', 'primary')]),
+ padding: { horizontal: 1, vertical: 0 },
+ emphasis: 'accent',
+ activity: model.busy ? 'busy' : 'idle',
+ },
+ ),
+ };
+ },
+ submit: (_submission, cx) => cx.backend(),
+});
diff --git a/crates/cli/tests/integration/candidate.rs b/crates/cli/tests/integration/candidate.rs
index 684a60b64f..2c993a663b 100644
--- a/crates/cli/tests/integration/candidate.rs
+++ b/crates/cli/tests/integration/candidate.rs
@@ -33,6 +33,8 @@ use std::{
time::{Duration, Instant},
};
+mod startup;
+
#[test]
fn candidate_preserves_root_authority_and_drains_on_owner_loss_or_released_idle() {
let directory = tempfile::tempdir().unwrap();
diff --git a/crates/cli/tests/integration/candidate/startup.rs b/crates/cli/tests/integration/candidate/startup.rs
new file mode 100644
index 0000000000..33578c3441
--- /dev/null
+++ b/crates/cli/tests/integration/candidate/startup.rs
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+
+fn start(directory: &Path) -> CandidateFixture {
+ let mut fixture = CandidateFixture::new(directory.join("root"));
+ fixture.child = Some(
+ Command::new(env!("CARGO_BIN_EXE_maka"))
+ .args(["host", "candidate", "--root"])
+ .arg(&fixture.root)
+ .args([
+ "--expected-root-id",
+ &fixture.root_id,
+ "--startup-attempt-id",
+ &uuid::Uuid::new_v4().to_string(),
+ "--owner-stdin",
+ "--initial-connection-timeout-ms",
+ "30000",
+ "--idle-grace-ms",
+ "1000",
+ ])
+ .stdin(Stdio::piped())
+ .stdout(Stdio::null())
+ .stderr(Stdio::inherit())
+ .spawn()
+ .unwrap(),
+ );
+ fixture.wait_for_registration();
+ fixture
+}
+
+#[test]
+fn candidate_owner_eof_preserves_an_independently_accepted_client_until_idle() {
+ let directory = tempfile::tempdir().unwrap();
+ let mut fixture = start(directory.path());
+ let runtime = tokio::runtime::Runtime::new().unwrap();
+ let discovery = maka_client::local::read_discovery(&fixture.root).unwrap();
+ let (client, notifications) = runtime.block_on(connect(&fixture.root));
+ assert_eq!(client.identity.root_id, discovery.root_id);
+ assert_eq!(client.identity.host_epoch, discovery.host_epoch);
+ assert_eq!(
+ runtime
+ .block_on(client.request(Operation::HostStatus, json!({})))
+ .unwrap()["state"],
+ "ready"
+ );
+ // The launcher has never handshaken or sent the owner-release message.
+ drop(fixture.child.as_mut().unwrap().stdin.take());
+ runtime.block_on(async {
+ tokio::time::timeout(Duration::from_secs(5), async {
+ loop {
+ let status = client
+ .request(Operation::HostDiagnosticsQuery, json!({}))
+ .await
+ .unwrap();
+ assert_eq!(status["hostEpoch"], discovery.host_epoch);
+ assert_eq!(status["state"], "ready");
+ if status["logs"].as_array().unwrap().iter().any(|entry| {
+ entry
+ .as_str()
+ .unwrap()
+ .contains("Startup launcher gone; accepted clients retain Host lifetime")
+ }) {
+ break;
+ }
+ tokio::task::yield_now().await;
+ }
+ })
+ .await
+ .expect("owner EOF must be processed while the observer stays attached");
+ assert_eq!(
+ client
+ .request(Operation::HostStatus, json!({}))
+ .await
+ .unwrap()["state"],
+ "ready"
+ );
+ let live = maka_client::local::read_discovery(&fixture.root).unwrap();
+ assert_eq!(live.root_id, discovery.root_id);
+ assert_eq!(live.host_epoch, discovery.host_epoch);
+ assert_eq!(live.pid, discovery.pid);
+ assert!(
+ RootOwner::open(
+ &fixture.root,
+ &RootNamespaces::for_current_account().unwrap()
+ )
+ .is_err()
+ );
+ client.disconnect();
+ client.closed().await;
+ });
+ drop(notifications);
+ assert!(fixture.wait_for_exit().success());
+ assert!(!fixture.registration.exists());
+ drop(
+ RootOwner::open(
+ &fixture.root,
+ &RootNamespaces::for_current_account().unwrap(),
+ )
+ .unwrap(),
+ );
+}
+
+#[test]
+fn candidate_owner_eof_without_an_accepted_client_drains_startup() {
+ let directory = tempfile::tempdir().unwrap();
+ let mut fixture = start(directory.path());
+ let discovery = maka_client::local::read_discovery(&fixture.root).unwrap();
+ assert_eq!(discovery.root_id, fixture.root_id);
+ assert_eq!(discovery.pid.get(), fixture.child.as_ref().unwrap().id());
+ drop(fixture.child.as_mut().unwrap().stdin.take());
+ assert!(fixture.wait_for_exit().success());
+ assert!(!fixture.registration.exists());
+ drop(
+ RootOwner::open(
+ &fixture.root,
+ &RootNamespaces::for_current_account().unwrap(),
+ )
+ .unwrap(),
+ );
+}
diff --git a/crates/cli/tests/integration/tui.rs b/crates/cli/tests/integration/tui.rs
index 8ccdfab839..a7ddf88cde 100644
--- a/crates/cli/tests/integration/tui.rs
+++ b/crates/cli/tests/integration/tui.rs
@@ -24,18 +24,21 @@ mod board;
mod branch;
mod builtins;
mod changes;
+mod cold_startup;
mod connection_test;
mod connections;
mod credentials;
mod enabled_models;
mod environment;
mod extensions;
+mod fault_acceptance;
mod forms;
mod frames;
mod goal;
mod imports;
mod input;
mod management;
+mod memory_acceptance;
mod model_fetch;
mod model_profiles;
mod models;
@@ -655,8 +658,18 @@ fn real_host_catalog_subscription_and_remote_updates_reach_clients() {
.unwrap();
});
observer.disconnect();
- tui.send(b"\x1b"); // Leave transcript focus, not the session.
- tui.wait_for("Show / hide session details"); // The last selected page control was Details.
+ tui.send(b"\x1b"); // Leave transcript focus for composer controls, not the session.
+ tui.wait_for("Attachments");
+ assert!(
+ tui.screen
+ .snapshot()
+ .unwrap()
+ .screen
+ .lines()
+ .next()
+ .is_some_and(|header| header.contains("Renamed from another")),
+ "first Esc preserves the session route"
+ );
tui.send(b"\x1b"); // Separate Esc events, not the terminal's Alt+Esc encoding.
tui.wait_for("另一份草稿🦀"); // Back follows the actual most recent tab visit.
tui.wait_for("Renamed from another");
@@ -914,6 +927,7 @@ impl Pty {
let mut command = Command::new(env!("CARGO_BIN_EXE_maka"));
// Theme fixtures must never read the developer's account-level file.
command.env_remove("MAKA_TUI_THEME");
+ command.env_remove("NO_COLOR");
if let Some(root) = args.windows(2).find(|pair| pair[0] == "--root") {
command.env(
"MAKA_TUI_STATE_DIR",
diff --git a/crates/cli/tests/integration/tui/attachments.rs b/crates/cli/tests/integration/tui/attachments.rs
index cb50c480be..9da674aaaf 100644
--- a/crates/cli/tests/integration/tui/attachments.rs
+++ b/crates/cli/tests/integration/tui/attachments.rs
@@ -120,7 +120,7 @@ fn local_file_upload_survives_routes_and_restart_then_sends_without_text() {
.join(&client.identity.root_id)
.join("default/state.json");
let saved: Value = serde_json::from_slice(&std::fs::read(&checkpoint).unwrap()).unwrap();
- assert_eq!(saved["version"], 20);
+ assert_eq!(saved["version"], 22);
let attachment = saved["attachments"]["files"][0]["attachment"].clone();
assert!(attachment.is_object());
let artifact = attachment["ref"]["relativePath"].as_str().unwrap();
diff --git a/crates/cli/tests/integration/tui/board.rs b/crates/cli/tests/integration/tui/board.rs
index 40a3a1391d..8c4d8cf95f 100644
--- a/crates/cli/tests/integration/tui/board.rs
+++ b/crates/cli/tests/integration/tui/board.rs
@@ -26,6 +26,7 @@ use serde_json::{Value, json};
mod activity;
mod lifecycle;
+pub(super) mod performance;
mod wire;
/// A JavaScript plugin the binary has never heard of, installed while the
@@ -41,10 +42,15 @@ fn a_javascript_plugin_installed_at_runtime_brings_its_own_app_into_the_running_
include_str!("../../fixtures/board-plugin/host.mjs"),
)
.unwrap();
+ std::fs::write(
+ package.join("board-ui.mjs"),
+ include_str!("../../fixtures/board-plugin/board-ui.mjs"),
+ )
+ .unwrap();
std::fs::write(
package.join("maka.extension.json"),
json!({"schemaVersion":1, "id":"example.board",
- "runtime":{"entry":"host.mjs", "sdkVersion":1}})
+ "runtime":{"entry":"host.mjs", "sdkVersion":2}})
.to_string(),
)
.unwrap();
diff --git a/crates/cli/tests/integration/tui/board/activity.rs b/crates/cli/tests/integration/tui/board/activity.rs
index 6468eb36e7..a3ed422437 100644
--- a/crates/cli/tests/integration/tui/board/activity.rs
+++ b/crates/cli/tests/integration/tui/board/activity.rs
@@ -133,7 +133,7 @@ pub(super) fn exercise(tui: &mut Pty, runtime: &Runtime, client: &maka_client::C
let closed = wait_stats(runtime, client, |stats| stats["active"] == 0);
assert_eq!(closed["closed"], closed["opened"]);
runtime.block_on(remote(client, "activity-append", json!(AWAY)));
- tui.click_text("Board");
+ tui.send(b"\x1b[1;3D"); // Global Back restores the complete activity address.
tui.wait_for("While away — 未显示。");
let reopened = wait_stats(runtime, client, |stats| stats["active"] == 1);
assert_eq!(
diff --git a/crates/cli/tests/integration/tui/board/performance.rs b/crates/cli/tests/integration/tui/board/performance.rs
new file mode 100644
index 0000000000..f5ef85478b
--- /dev/null
+++ b/crates/cli/tests/integration/tui/board/performance.rs
@@ -0,0 +1,288 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::super::super::candidate::CandidateFixture;
+use super::*;
+use tokio::runtime::Runtime;
+
+mod pending;
+pub(in crate::tui) mod proxy;
+mod report;
+mod timed;
+mod visual;
+
+const READY: &str = "Live activity — ready.";
+const ANCHOR: &str = "Board history 150";
+
+/// Fixed release acceptance: 20 warmups + 200 samples per operation/delay.
+/// MAKA_TUI_PERF_MODE=smoke selects 2+8 and never claims performance acceptance.
+/// MAKA_TUI_PERF_OUTPUT may name a new JSONL file; every raw row also goes to stdout.
+#[test]
+#[ignore = "serial release Host+PTY performance acceptance; emits raw JSONL"]
+fn local_input_scroll_and_disclosure_under_real_host_rtt() {
+ let mut report = report::Report::new();
+ for rtt in [0, 200] {
+ run(rtt, &mut report, false);
+ }
+ report.finish();
+ assert_eq!(
+ report.failures, 0,
+ "raw JSONL retains all observed failures"
+ );
+}
+
+fn run(rtt: u64, report: &mut report::Report, extended: bool) {
+ let directory = tempfile::tempdir().unwrap();
+ let package = directory.path().join("board-plugin");
+ std::fs::create_dir(&package).unwrap();
+ let board = include_str!("../../../fixtures/board-plugin/host.mjs");
+ let entry = if extended {
+ pending::fixture(board)
+ } else {
+ board.to_owned()
+ };
+ std::fs::write(package.join("host.mjs"), entry).unwrap();
+ std::fs::write(
+ package.join("board-ui.mjs"),
+ include_str!("../../../fixtures/board-plugin/board-ui.mjs"),
+ )
+ .unwrap();
+ std::fs::write(
+ package.join("maka.extension.json"),
+ json!({"schemaVersion":1,
+ "id":"example.board","runtime":{"entry":"host.mjs","sdkVersion":2}})
+ .to_string(),
+ )
+ .unwrap();
+ let mut host = CandidateFixture::new(directory.path().join("root"));
+ host.child = Some(
+ Command::new(env!("CARGO_BIN_EXE_maka"))
+ .args(["host", "serve", "--root"])
+ .arg(&host.root)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::inherit())
+ .spawn()
+ .unwrap(),
+ );
+ host.wait_for_registration();
+ let runtime = Runtime::new().unwrap();
+ let (client, notices) = runtime.block_on(proxy::connect(&host.root));
+ runtime.block_on(async {
+ client
+ .request(
+ Operation::PluginPackageInstall,
+ json!({"sourcePath":package}),
+ )
+ .await
+ .unwrap();
+ let receipt = client
+ .request(
+ Operation::PluginCompositionApply,
+ json!({"operations":[{"type":"insert",
+ "rootId":"profile","entry":{"id":"board","packageId":"example.board"}}]}),
+ )
+ .await
+ .unwrap();
+ lifecycle::settle(&client, false, &receipt).await;
+ });
+ let origin = Instant::now();
+ let proxy = runtime.block_on(proxy::DelayProxy::start(
+ &host,
+ directory.path(),
+ Duration::from_millis(rtt / 2),
+ origin,
+ ));
+ let tui = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+ let mut timed = timed::Timed::new(tui, origin);
+ let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ timed.tui.resize(170, 40);
+ assert!(timed.input(b"", |s| s.contains("Board"))["error"].is_null());
+ let click = timed::click(&timed.screen(), "Board");
+ assert!(timed.input(&click, |s| s.contains("A new card"))["error"].is_null());
+ let setup = timed.settled(&proxy, true);
+ report.emit(json!({"kind":"setup_fence","added_rtt_ms":rtt,"fence":setup}));
+ report.rss(
+ rtt,
+ "board_loaded",
+ host.child.as_ref().unwrap().id(),
+ timed.tui.child.id(),
+ );
+ if extended {
+ pending::exercise(&mut timed, &runtime, &client, &proxy, rtt, report);
+ return;
+ }
+ let before = timed.source(&runtime, &client, &proxy);
+ let click = timed::click(&timed.screen(), "A new card");
+ assert!(timed.input(&click, |s| s.contains("A new card"))["error"].is_null());
+ let (input_row, input_col) = timed::position(&timed.screen(), "A new card");
+ // Each insertion starts empty; its paired backspace restores that state.
+ for index in 0..report.warmup() + report.count() {
+ if !report.sample(
+ rtt,
+ "input_insert",
+ index,
+ timed.input(b"x", |screen| {
+ timed::field_character(screen, input_row, input_col, 'x')
+ }),
+ ) {
+ break;
+ }
+ if !report.sample(
+ rtt,
+ "input_backspace",
+ index,
+ timed.input(b"\x7f", |screen| screen.contains("A new card")),
+ ) {
+ break;
+ }
+ }
+ report.source(
+ rtt,
+ "input",
+ &before,
+ timed.source(&runtime, &client, &proxy),
+ );
+ let click = timed::click(&timed.screen(), "Board activity");
+ assert!(timed.input(&click, |s| s.contains(READY))["error"].is_null());
+ let click = timed::click(&timed.screen(), READY);
+ assert!(timed.input(&click, |s| s.contains(READY))["error"].is_null());
+ // An interior loaded record avoids the remote Newer/Older edge actions.
+ timed.find(ANCHOR);
+ let before = timed.source(&runtime, &client, &proxy);
+ for index in 0..report.warmup() + report.count() {
+ let (row, col) = timed::position(&timed.screen(), ANCHOR);
+ let up = format!("\x1b[<64;{};{}M", col + 1, row + 1);
+ if !report.sample(
+ rtt,
+ "loaded_scroll_up",
+ index,
+ timed.input(up.as_bytes(), |screen| {
+ screen
+ .lines()
+ .position(|line| line.contains(ANCHOR))
+ .is_some_and(|next| next > row)
+ }),
+ ) {
+ break;
+ }
+ let (moved, col) = timed::position(&timed.screen(), ANCHOR);
+ let down = format!("\x1b[<65;{};{}M", col + 1, moved + 1);
+ if !report.sample(
+ rtt,
+ "loaded_scroll_down",
+ index,
+ timed.input(down.as_bytes(), |screen| {
+ screen.lines().position(|line| line.contains(ANCHOR)) == Some(row)
+ }),
+ ) {
+ break;
+ }
+ }
+ report.source(
+ rtt,
+ "loaded_scroll",
+ &before,
+ timed.source(&runtime, &client, &proxy),
+ );
+ timed.find("Board report end — 完整记录。");
+ assert!(timed.screen().contains("Read × 2"));
+ let before = timed.source(&runtime, &client, &proxy);
+ for index in 0..report.warmup() + report.count() {
+ let bytes = timed::click(&timed.screen(), "Read × 2");
+ if !report.sample(
+ rtt,
+ "disclosure_open",
+ index,
+ timed.input(&bytes, |screen| screen.contains("Read cards")),
+ ) {
+ break;
+ }
+ let bytes = timed::click(&timed.screen(), "Read × 2");
+ if !report.sample(
+ rtt,
+ "disclosure_close",
+ index,
+ timed.input(&bytes, |screen| !screen.contains("Read cards")),
+ ) {
+ break;
+ }
+ }
+ report.source(
+ rtt,
+ "disclosure",
+ &before,
+ timed.source(&runtime, &client, &proxy),
+ );
+ report.rss(
+ rtt,
+ "operations_finished",
+ host.child.as_ref().unwrap().id(),
+ timed.tui.child.id(),
+ );
+ }));
+ if let Err(error) = outcome {
+ let message = error
+ .downcast_ref::<String>()
+ .map(String::as_str)
+ .or_else(|| error.downcast_ref::<&str>().copied())
+ .unwrap_or("non-string panic");
+ report.emit(json!({"kind":"case_error","added_rtt_ms":rtt,"error":message}));
+ report.failures += 1;
+ }
+ if extended {
+ runtime.block_on(remote(&client, "read-control", json!({"op":"release"})));
+ timed.settled(&proxy, false);
+ }
+ // Close the interface through its real local command and let the Host drain.
+ // CandidateFixture/Pty kill fallbacks are never counted as successful teardown.
+ timed.tui.filter_command("Close interface only");
+ timed.tui.click_text("Close interface only");
+ timed.tui.finish();
+ let closed = runtime.block_on(async {
+ let deadline = Instant::now() + Duration::from_secs(5);
+ loop {
+ let stats = remote(&client, "activity-stats", Value::Null).await;
+ if stats["active"] == 0 || Instant::now() >= deadline {
+ break stats;
+ }
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ });
+ let fence = proxy.fence();
+ report.emit(json!({"kind":"source_closed","added_rtt_ms":rtt,"stats":closed,"fence":fence}));
+ if closed["active"] != 0 || closed["closed"] != closed["opened"] || fence["live_documents"] != 0
+ {
+ report.failures += 1;
+ }
+ let records = runtime.block_on(proxy.stop());
+ report.wire(rtt, records);
+ client.disconnect();
+ runtime.block_on(async {
+ client.closed().await;
+ notices.await.unwrap();
+ });
+ host.retire_registered();
+ let status = host.wait_for_exit();
+ report.emit(
+ json!({"kind":"drain","added_rtt_ms":rtt,"host_success":status.success(),
+ "registration_removed":!host.registration.exists()}),
+ );
+ assert!(status.success() && !host.registration.exists());
+}
diff --git a/crates/cli/tests/integration/tui/board/performance/gated.mjs b/crates/cli/tests/integration/tui/board/performance/gated.mjs
new file mode 100644
index 0000000000..9cf8136747
--- /dev/null
+++ b/crates/cli/tests/integration/tui/board/performance/gated.mjs
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Only the performance package installs this wrapper. The shared Board fixture
+// activation is prepended as activateBoard in one import-free Host entrypoint.
+export default async function activate(ctx) {
+ let notify;
+ let wake;
+ let armed = false;
+ let pending = false;
+ let entered = 0;
+ let released = 0;
+ const status = () => ({ armed, pending, entered, released });
+ await ctx.remote.method('read-control', (input) => {
+ if (input.op === 'arm') {
+ if (armed || pending) throw new Error('Read gate already armed');
+ armed = true;
+ notify();
+ } else if (input.op === 'release') {
+ armed = false;
+ wake?.();
+ wake = undefined;
+ }
+ return status();
+ });
+ const tui = {
+ ...ctx.tui,
+ async changes(name) {
+ const changed = await ctx.tui.changes(name);
+ if (name === 'board-changed') notify = changed;
+ return changed;
+ },
+ async app(name, registration, descriptor, options) {
+ return ctx.tui.app(
+ name,
+ {
+ ...registration,
+ async backend(request, cx) {
+ if (request.kind === 'read' && armed) {
+ armed = false;
+ pending = true;
+ entered++;
+ await new Promise((resolve) => {
+ wake = resolve;
+ });
+ pending = false;
+ released++;
+ }
+ return registration.backend(request, cx);
+ },
+ },
+ descriptor,
+ options,
+ );
+ },
+ };
+ await activateBoard({ ...ctx, tui });
+}
diff --git a/crates/cli/tests/integration/tui/board/performance/pending.rs b/crates/cli/tests/integration/tui/board/performance/pending.rs
new file mode 100644
index 0000000000..b1f461cf76
--- /dev/null
+++ b/crates/cli/tests/integration/tui/board/performance/pending.rs
@@ -0,0 +1,212 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+
+pub(super) mod gate;
+
+pub(super) fn fixture(board: &str) -> String {
+ let activation = "export default async function activate(ctx)";
+ assert_eq!(
+ board.matches(activation).count(),
+ 1,
+ "single Board activation"
+ );
+ let board = board.replacen(activation, "async function activateBoard(ctx)", 1);
+ format!("{board}\n{}", include_str!("gated.mjs"))
+}
+
+#[test]
+#[ignore = "serial release Host+PTY pending-Read and drag acceptance; emits raw JSONL"]
+fn pending_read_keeps_local_input_scroll_disclosure_and_drag_responsive() {
+ let mut report = report::Report::scenario(
+ "provider-held semantic Board Read; loaded local operations; idle/pending cell-style drag",
+ &[
+ "pending_input_insert",
+ "pending_input_backspace",
+ "pending_loaded_scroll_up",
+ "pending_loaded_scroll_down",
+ "pending_disclosure_open",
+ "pending_disclosure_close",
+ "idle_drag_extend",
+ "idle_drag_shrink",
+ "pending_drag_extend",
+ "pending_drag_shrink",
+ ],
+ );
+ for rtt in [0, 200] {
+ run(rtt, &mut report, true);
+ }
+ report.finish();
+ assert_eq!(
+ report.failures, 0,
+ "raw JSONL retains all observed failures"
+ );
+}
+
+pub(super) fn exercise(
+ timed: &mut timed::Timed,
+ runtime: &Runtime,
+ client: &maka_client::Client,
+ proxy: &proxy::DelayProxy,
+ rtt: u64,
+ report: &mut report::Report,
+) {
+ timed.visual = Some(visual::Visual::new(timed.tui.screen.size()));
+ // A real resize supplies a complete current screen to the visual observer;
+ // ordinary text-only fixture setup above was deliberately not timed.
+ for columns in [169, 170] {
+ timed.tui.resize(columns, 40);
+ assert!(timed.input(b"", |s| s.contains("A new card"))["error"].is_null());
+ }
+ timed.tui.send(&timed::click(&timed.screen(), "A new card"));
+ phase(timed, runtime, client, proxy, report, (rtt, "input"));
+ let click = timed::click(&timed.screen(), "Board activity");
+ assert!(timed.input(&click, |s| s.contains(READY))["error"].is_null());
+ timed.tui.send(&timed::click(&timed.screen(), READY));
+ timed.settled(proxy, false);
+ timed.find(ANCHOR);
+ let before = timed.source(runtime, client, proxy);
+ visual::drag(timed, proxy, report, rtt, None);
+ report.source(
+ rtt,
+ "idle_drag",
+ &before,
+ timed.source(runtime, client, proxy),
+ );
+ phase(
+ timed,
+ runtime,
+ client,
+ proxy,
+ report,
+ (rtt, "loaded_scroll"),
+ );
+ phase(timed, runtime, client, proxy, report, (rtt, "drag"));
+ timed.find("Board report end — 完整记录。");
+ assert!(timed.screen().contains("Read × 2"));
+ phase(timed, runtime, client, proxy, report, (rtt, "disclosure"));
+}
+
+fn phase(
+ timed: &mut timed::Timed,
+ runtime: &Runtime,
+ client: &maka_client::Client,
+ proxy: &proxy::DelayProxy,
+ report: &mut report::Report,
+ (rtt, phase): (u64, &str),
+) {
+ let hold = gate::Hold::begin(timed, runtime, client, proxy);
+ report.emit(
+ json!({"kind":"pending_entered","added_rtt_ms":rtt,"phase":phase,
+ "provider":hold.entered,"source":hold.before}),
+ );
+ if phase == "drag" {
+ visual::drag(timed, proxy, report, rtt, Some(&hold));
+ } else {
+ for index in 0..report.warmup() + report.count() {
+ let completed = match phase {
+ "input" => {
+ let (row, col) = timed::position(&timed.screen(), "A new card");
+ report.sample(
+ rtt,
+ "pending_input_insert",
+ index,
+ hold.during(proxy, || {
+ timed.input(b"x", |s| timed::field_character(s, row, col, 'x'))
+ }),
+ ) && report.sample(
+ rtt,
+ "pending_input_backspace",
+ index,
+ hold.during(proxy, || timed.input(b"\x7f", |s| s.contains("A new card"))),
+ )
+ }
+ "loaded_scroll" => {
+ let (row, col) = timed::position(&timed.screen(), ANCHOR);
+ let up = format!("\x1b[<64;{};{}M", col + 1, row + 1);
+ let ok = report.sample(
+ rtt,
+ "pending_loaded_scroll_up",
+ index,
+ hold.during(proxy, || {
+ timed.input(up.as_bytes(), |s| {
+ s.lines()
+ .position(|line| line.contains(ANCHOR))
+ .is_some_and(|next| next > row)
+ })
+ }),
+ );
+ if !ok {
+ false
+ } else {
+ let (moved, col) = timed::position(&timed.screen(), ANCHOR);
+ let down = format!("\x1b[<65;{};{}M", col + 1, moved + 1);
+ report.sample(
+ rtt,
+ "pending_loaded_scroll_down",
+ index,
+ hold.during(proxy, || {
+ timed.input(down.as_bytes(), |s| {
+ s.lines().position(|line| line.contains(ANCHOR)) == Some(row)
+ })
+ }),
+ )
+ }
+ }
+ "disclosure" => {
+ let open = timed::click(&timed.screen(), "Read × 2");
+ let ok = report.sample(
+ rtt,
+ "pending_disclosure_open",
+ index,
+ hold.during(proxy, || timed.input(&open, |s| s.contains("Read cards"))),
+ );
+ if !ok {
+ false
+ } else {
+ let close = timed::click(&timed.screen(), "Read × 2");
+ report.sample(
+ rtt,
+ "pending_disclosure_close",
+ index,
+ hold.during(proxy, || {
+ timed.input(&close, |s| !s.contains("Read cards"))
+ }),
+ )
+ }
+ }
+ _ => unreachable!(),
+ };
+ if !completed {
+ break;
+ }
+ }
+ }
+ let evidence = hold.end(timed, runtime, client, proxy);
+ report.source(
+ rtt,
+ &format!("pending_{phase}"),
+ &evidence["before"],
+ evidence["after"].clone(),
+ );
+ report.emit(
+ json!({"kind":"pending_released","added_rtt_ms":rtt,"phase":phase,"evidence":evidence}),
+ );
+}
diff --git a/crates/cli/tests/integration/tui/board/performance/pending/gate.rs b/crates/cli/tests/integration/tui/board/performance/pending/gate.rs
new file mode 100644
index 0000000000..52f96dfeb7
--- /dev/null
+++ b/crates/cli/tests/integration/tui/board/performance/pending/gate.rs
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+
+pub(in super::super) struct Hold {
+ request: Value,
+ pub before: Value,
+ pub entered: Value,
+}
+
+impl Hold {
+ pub fn begin(
+ timed: &mut timed::Timed,
+ runtime: &Runtime,
+ client: &maka_client::Client,
+ proxy: &proxy::DelayProxy,
+ ) -> Self {
+ let boundary = timed.source(runtime, client, proxy);
+ let armed = runtime.block_on(remote(client, "read-control", json!({"op":"arm"})));
+ assert_eq!(armed["armed"], true);
+ let expected = armed["entered"].as_u64().unwrap() + 1;
+ let deadline = Instant::now() + Duration::from_secs(5);
+ loop {
+ timed
+ .read()
+ .expect("PTY while waiting for real presenter Read gate");
+ let entered = runtime.block_on(remote(client, "read-control", json!({"op":"status"})));
+ let requests: Vec<_> = proxy
+ .records()
+ .into_iter()
+ .filter(|row| {
+ row["direction"] == "client_to_host"
+ && row["method"] == "board"
+ && row["remote_kind"] == "call"
+ && row["remote_input_kind"] == "read"
+ && row["received_ns"].as_u64().is_some_and(|ns| {
+ ns > boundary["fence"]["observed_ns"].as_u64().unwrap()
+ })
+ && row["forwarded_ns"].is_u64()
+ })
+ .collect();
+ if entered["pending"] == true && entered["entered"] == expected && requests.len() == 1 {
+ let hold = Self {
+ request: requests[0].clone(),
+ entered,
+ before: json!({"stats":runtime.block_on(remote(client,"activity-stats",Value::Null)),
+ "fence":proxy.fence()}),
+ };
+ assert_eq!(hold.state(proxy)["pending"], true);
+ return hold;
+ }
+ assert!(
+ Instant::now() < deadline,
+ "presenter Read did not enter gate: {entered}; requests={requests:?}"
+ );
+ }
+ }
+
+ fn state(&self, proxy: &proxy::DelayProxy) -> Value {
+ let records = proxy.records();
+ let reply = records.iter().find(|row| {
+ row["direction"] == "host_to_client"
+ && row["connection"] == self.request["connection"]
+ && row["request_id"] == self.request["request_id"]
+ });
+ let closed = records.iter().any(|row| {
+ row["direction"] == "client_to_host"
+ && row["connection"] == self.request["connection"]
+ && row["remote_kind"] == "close_document"
+ && row["document"] == self.request["document"]
+ });
+ json!({"request":self.request,"reply":reply,"close_requested":closed,
+ "pending":reply.is_none() && !closed})
+ }
+
+ pub fn during(&self, proxy: &proxy::DelayProxy, action: impl FnOnce() -> Value) -> Value {
+ let before = self.state(proxy);
+ let mut sample = action();
+ let after = self.state(proxy);
+ if before["pending"] != true
+ || after["pending"] != true
+ || self.request["forwarded_ns"].as_u64().unwrap() > sample["start_ns"].as_u64().unwrap()
+ {
+ sample["prior_error"] = sample["error"].clone();
+ sample["error"] = json!("semantic Read was not held pending throughout local input");
+ sample["duration_ns"] = Value::Null;
+ }
+ sample["pending_read"] =
+ json!({"before":before,"after":after,"provider_entered":self.entered});
+ sample
+ }
+
+ pub fn end(
+ self,
+ timed: &mut timed::Timed,
+ runtime: &Runtime,
+ client: &maka_client::Client,
+ proxy: &proxy::DelayProxy,
+ ) -> Value {
+ let pending = self.state(proxy);
+ let entered = runtime.block_on(remote(client, "read-control", json!({"op":"status"})));
+ let after = json!({"stats":runtime.block_on(remote(client,"activity-stats",Value::Null)),
+ "fence":proxy.fence()});
+ assert_eq!(pending["pending"], true);
+ assert_eq!(
+ entered, self.entered,
+ "provider gate state changed during local operations"
+ );
+ runtime.block_on(remote(client, "read-control", json!({"op":"release"})));
+ let settled = timed.settled(proxy, false);
+ let state = self.state(proxy);
+ // Releasing a finite Read keeps its page document alive. No new finite
+ // request is allowed, including local RPCs queued behind the delay.
+ assert_eq!(
+ settled["semantic_calls"],
+ self.before["fence"]["semantic_calls"]
+ );
+ assert_eq!(
+ settled["remote_requests_excluding_next"].as_u64().unwrap(),
+ self.before["fence"]["remote_requests_excluding_next"]
+ .as_u64()
+ .unwrap()
+ );
+ let released = runtime.block_on(remote(client, "read-control", json!({"op":"status"})));
+ assert_eq!(state["reply"]["result_kind"], "value");
+ assert!(state["reply"]["forwarded_ns"].is_u64());
+ assert_eq!(state["close_requested"], false);
+ assert_eq!(released["pending"], false);
+ assert_eq!(released["entered"], released["released"]);
+ json!({"before":self.before,"after":after,"provider_before_release":entered,
+ "provider_after_release":released,"read_after_release":state,"settled":settled})
+ }
+}
diff --git a/crates/cli/tests/integration/tui/board/performance/proxy.rs b/crates/cli/tests/integration/tui/board/performance/proxy.rs
new file mode 100644
index 0000000000..8865681f87
--- /dev/null
+++ b/crates/cli/tests/integration/tui/board/performance/proxy.rs
@@ -0,0 +1,220 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+mod fence;
+
+use super::*;
+use std::{
+ path::PathBuf,
+ sync::{Arc, Mutex},
+};
+use tokio::{
+ io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
+ net::unix::{OwnedReadHalf, OwnedWriteHalf},
+};
+use tokio_util::sync::CancellationToken;
+
+pub(in crate::tui) struct DelayProxy {
+ registration: PathBuf,
+ original: Vec<u8>,
+ cancel: CancellationToken,
+ task: Option<tokio::task::JoinHandle<()>>,
+ records: Arc<Mutex<Vec<Value>>>,
+}
+
+impl DelayProxy {
+ /// Only the freshly created CandidateFixture's discovery is replaced.
+ /// Identity, epoch and every wire byte retain their original meaning.
+ pub async fn start(
+ host: &CandidateFixture,
+ directory: &std::path::Path,
+ one_way: Duration,
+ origin: Instant,
+ ) -> Self {
+ let discovery = maka_client::local::read_discovery(&host.root).unwrap();
+ assert_eq!(discovery.root_id, host.root_id);
+ let original = std::fs::read(&host.registration).unwrap();
+ let socket = directory.join("performance.sock");
+ let listener = tokio::net::UnixListener::bind(&socket).unwrap();
+ let cancel = CancellationToken::new();
+ let stop = cancel.clone();
+ let records = Arc::new(Mutex::new(Vec::new()));
+ let captured = records.clone();
+ let task = tokio::spawn(async move {
+ let mut connections = tokio::task::JoinSet::new();
+ let mut connection = 0_u64;
+ loop {
+ tokio::select! {
+ _ = stop.cancelled() => break,
+ finished = connections.join_next(), if !connections.is_empty() => {
+ if let Err(error) = finished.unwrap() {
+ captured.lock().unwrap().push(json!({"kind":"proxy_error", "error":error.to_string()}));
+ }
+ },
+ accepted = listener.accept() => {
+ let (client, _) = accepted.unwrap();
+ connection += 1;
+ let endpoint = discovery.endpoint.clone();
+ let records = captured.clone();
+ let stop = stop.clone();
+ connections.spawn(async move {
+ let host = tokio::net::UnixStream::connect(endpoint).await.unwrap();
+ let (client_read, client_write) = client.into_split();
+ let (host_read, host_write) = host.into_split();
+ let up = direction(client_read, host_write, one_way, origin, connection, "client_to_host", records.clone());
+ let down = direction(host_read, client_write, one_way, origin, connection, "host_to_client", records.clone());
+ let result = tokio::select! {
+ result = up => result,
+ result = down => result,
+ _ = stop.cancelled() => Ok(()),
+ };
+ if let Err(error) = result {
+ let kind = if matches!(error.kind(), std::io::ErrorKind::BrokenPipe
+ | std::io::ErrorKind::ConnectionReset | std::io::ErrorKind::ConnectionAborted) {
+ "proxy_disconnect"
+ } else { "proxy_error" };
+ records.lock().unwrap().push(json!({"kind":kind, "connection":connection, "error":error.to_string()}));
+ }
+ });
+ }
+ }
+ }
+ // Cancellation drops both directional futures; all connection tasks
+ // are joined before this case returns, including their queue timers.
+ while let Some(result) = connections.join_next().await {
+ result.expect("delay proxy connection task");
+ }
+ });
+ let mut record: Value = serde_json::from_slice(&original).unwrap();
+ record["endpoint"] = json!(socket);
+ std::fs::write(&host.registration, serde_json::to_vec(&record).unwrap()).unwrap();
+ Self {
+ registration: host.registration.clone(),
+ original,
+ cancel,
+ task: Some(task),
+ records,
+ }
+ }
+
+ pub async fn stop(mut self) -> Vec<Value> {
+ std::fs::write(&self.registration, &self.original).unwrap();
+ self.cancel.cancel();
+ self.task.take().unwrap().await.expect("delay proxy task");
+ self.records.lock().unwrap().clone()
+ }
+
+ pub fn fence(&self) -> Value {
+ fence::snapshot(&self.records.lock().unwrap())
+ }
+
+ pub fn records(&self) -> Vec<Value> {
+ self.records.lock().unwrap().clone()
+ }
+}
+
+impl Drop for DelayProxy {
+ fn drop(&mut self) {
+ self.cancel.cancel();
+ if let Some(task) = self.task.take() {
+ task.abort(); // Panic fallback only; the successful path always joins.
+ let _ = std::fs::write(&self.registration, &self.original);
+ }
+ }
+}
+
+async fn direction(
+ reader: OwnedReadHalf,
+ mut writer: OwnedWriteHalf,
+ delay: Duration,
+ origin: Instant,
+ connection: u64,
+ direction: &'static str,
+ records: Arc<Mutex<Vec<Value>>>,
+) -> std::io::Result<()> {
+ // The reader keeps receiving while the writer waits for absolute due times.
+ // Delays therefore overlap instead of adding 100 ms per queued wire frame.
+ let (send, mut receive) = tokio::sync::mpsc::channel::<(Vec<u8>, Instant, usize)>(32);
+ let captured = records.clone();
+ let ingest = async move {
+ let mut reader = BufReader::new(reader);
+ loop {
+ let mut bytes = Vec::new();
+ if reader.read_until(b'\n', &mut bytes).await? == 0 {
+ break;
+ }
+ if bytes.len() > 4 * 1024 * 1024 {
+ return Err(std::io::Error::other(
+ "proxy NDJSON frame exceeds fixture budget",
+ ));
+ }
+ let received = Instant::now();
+ let value: Value = serde_json::from_slice(&bytes).map_err(std::io::Error::other)?;
+ let index = {
+ let mut records = captured.lock().unwrap();
+ if records.len() >= 100_000 {
+ return Err(std::io::Error::other("proxy evidence budget exhausted"));
+ }
+ let index = records.len();
+ records.push(json!({"kind":"wire_received", "direction":direction, "connection":connection,
+ "received_ns":received.duration_since(origin).as_nanos() as u64,"forwarded_ns":null,
+ "wire_bytes":bytes.len(),"request_id":value["requestId"],"operation":value["operation"],
+ "remote_kind":value["input"]["kind"],"method":value["input"]["binding"]["method"],
+ "remote_input_kind":value["input"]["input"]["kind"],"document":value["input"]["document"],
+ "result_kind":value["result"]["kind"],"result_document":value["result"]["document"]}));
+ index
+ };
+ if send.send((bytes, received, index)).await.is_err() {
+ break;
+ }
+ }
+ Ok::<_, std::io::Error>(())
+ };
+ let forward = async move {
+ while let Some((bytes, received, index)) = receive.recv().await {
+ tokio::time::sleep_until(tokio::time::Instant::from_std(received + delay)).await;
+ writer.write_all(&bytes).await?;
+ let forwarded = Instant::now();
+ let mut records = records.lock().unwrap();
+ records[index]["kind"] = json!("wire");
+ records[index]["forwarded_ns"] =
+ json!(forwarded.duration_since(origin).as_nanos() as u64);
+ }
+ writer.shutdown().await
+ };
+ tokio::try_join!(ingest, forward).map(|_| ())
+}
+
+pub(in crate::tui) async fn connect(
+ root: &std::path::Path,
+) -> (maka_client::Client, tokio::task::JoinHandle<()>) {
+ let discovery = maka_client::local::read_discovery(root).unwrap();
+ let (client, mut notices) = maka_client::Client::connect(
+ maka_client::local::open_stream(&discovery.endpoint)
+ .await
+ .unwrap(),
+ &discovery.root_id,
+ &discovery.host_epoch,
+ maka_client::Operations,
+ )
+ .await
+ .unwrap();
+ let task = tokio::spawn(async move { while notices.recv().await.is_some() {} });
+ (client, task)
+}
diff --git a/crates/cli/tests/integration/tui/board/performance/proxy/fence.rs b/crates/cli/tests/integration/tui/board/performance/proxy/fence.rs
new file mode 100644
index 0000000000..b919796384
--- /dev/null
+++ b/crates/cli/tests/integration/tui/board/performance/proxy/fence.rs
@@ -0,0 +1,198 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use serde_json::{Value, json};
+
+pub(super) fn snapshot(records: &[Value]) -> Value {
+ use std::collections::{BTreeMap, BTreeSet};
+ let identity = |row: &Value| (row["connection"].to_string(), row["request_id"].to_string());
+ let replies: BTreeMap<_, _> = records
+ .iter()
+ .filter(|row| row["direction"] == "host_to_client" && row["forwarded_ns"].is_u64())
+ .map(|row| (identity(row), row))
+ .collect();
+ let requests: Vec<_> = records
+ .iter()
+ .filter(|row| row["direction"] == "client_to_host" && row["request_id"].is_string())
+ .collect();
+ let mut allocated = BTreeSet::new();
+ let mut pages = BTreeSet::new();
+ let mut closed = BTreeSet::new();
+ let (mut pending, mut remote_requests, mut calls, mut latest_call_reply_ns) = (0, 0, 0, 0);
+ let current = requests
+ .iter()
+ .rev()
+ .find(|request| request["remote_kind"] == "call" && request["method"] == "board");
+ let current_document = current.map(|request| {
+ (
+ request["connection"].to_string(),
+ request["document"].to_string(),
+ )
+ });
+ let mut watch_open = 0;
+ for request in &requests {
+ let reply = replies.get(&identity(request));
+ if request["remote_kind"] != "next" {
+ pending += usize::from(reply.is_none());
+ remote_requests += usize::from(request["operation"] == "plugin.remote");
+ }
+ calls += usize::from(request["remote_kind"] == "call");
+ let document = (
+ request["connection"].to_string(),
+ request["document"].to_string(),
+ );
+ match request["remote_kind"].as_str() {
+ Some("open_document") => {
+ if let Some(reply) = reply.filter(|reply| reply["result_kind"] == "document") {
+ allocated.insert((
+ request["connection"].to_string(),
+ reply["result_document"].to_string(),
+ ));
+ }
+ }
+ Some("open") => {
+ if request["method"] == "board-changed"
+ && Some(&document) == current_document.as_ref()
+ && let Some(reply) = reply.filter(|reply| reply["result_kind"] == "opened")
+ {
+ watch_open = watch_open.max(reply["forwarded_ns"].as_u64().unwrap());
+ }
+ }
+ Some("close_document")
+ if reply.is_some_and(|reply| reply["result_kind"] == "closed") =>
+ {
+ closed.insert(document);
+ }
+ Some("call") => {
+ if request["method"] == "board" {
+ pages.insert(document);
+ }
+ if let Some(reply) = reply {
+ latest_call_reply_ns =
+ latest_call_reply_ns.max(reply["forwarded_ns"].as_u64().unwrap());
+ }
+ }
+ _ => {}
+ }
+ }
+ let watch_refresh_settled = watch_open > 0
+ && requests.iter().any(|request| {
+ request["method"] == "board"
+ && request["remote_input_kind"] == "read"
+ && request["received_ns"].as_u64().unwrap() > watch_open
+ && Some(&(
+ request["connection"].to_string(),
+ request["document"].to_string(),
+ )) == current_document.as_ref()
+ && replies
+ .get(&identity(request))
+ .is_some_and(|reply| reply["result_kind"] == "value")
+ });
+ // Page calls and observations share one live document. Only retiring
+ // the page closes it; a successful finite Call does not.
+ let live: BTreeSet<_> = allocated.difference(&closed).cloned().collect();
+ let unowned_documents = live
+ .iter()
+ .filter(|document| !pages.contains(*document))
+ .count();
+ json!({"requests":requests.len(), "remote_requests_excluding_next":remote_requests,
+ "semantic_calls":calls, "pending_finite_requests":pending,
+ "live_documents":live.len(),"live_page_documents":live.len()-unowned_documents,
+ "unowned_documents":unowned_documents,"watch_refresh_settled":watch_refresh_settled,
+ "latest_call_reply_ns":latest_call_reply_ns})
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn request(rows: &mut Vec<Value>, id: &str, kind: &str, doc: &str, method: &str, ns: u64) {
+ rows.push(
+ json!({"direction":"client_to_host","connection":1,"request_id":id,
+ "operation":"plugin.remote","remote_kind":kind,"method":method,
+ "document":doc,"received_ns":ns,"forwarded_ns":ns,"remote_input_kind":"read"}),
+ );
+ }
+
+ fn reply(rows: &mut Vec<Value>, id: &str, kind: &str, doc: &str, ns: Option<u64>) {
+ rows.push(
+ json!({"direction":"host_to_client","connection":1,"request_id":id,
+ "result_kind":kind,"result_document":doc,"forwarded_ns":ns}),
+ );
+ }
+
+ fn page(rows: &mut Vec<Value>, doc: &str, start: u64) {
+ let allocate = format!("allocate-{doc}");
+ request(rows, &allocate, "open_document", "", "", start);
+ reply(rows, &allocate, "document", doc, Some(start + 1));
+ let read = format!("initial-{doc}");
+ request(rows, &read, "call", doc, "board", start + 2);
+ reply(rows, &read, "value", doc, Some(start + 3));
+ }
+
+ #[test]
+ fn a_page_survives_replied_calls_but_pending_and_unforwarded_replies_do_not_settle() {
+ let mut rows = vec![];
+ page(&mut rows, "page", 10);
+ request(&mut rows, "watch", "open", "page", "board-changed", 20);
+ reply(&mut rows, "watch", "opened", "page", Some(30));
+ request(&mut rows, "refresh", "call", "page", "board", 40);
+ assert_eq!(snapshot(&rows)["pending_finite_requests"], 1);
+ assert_eq!(snapshot(&rows)["watch_refresh_settled"], false);
+ reply(&mut rows, "refresh", "value", "page", None);
+ assert_eq!(snapshot(&rows)["pending_finite_requests"], 1);
+ assert_eq!(snapshot(&rows)["watch_refresh_settled"], false);
+ rows.last_mut().unwrap()["forwarded_ns"] = json!(50);
+ request(&mut rows, "next", "next", "page", "", 60);
+ let settled = snapshot(&rows);
+ assert_eq!(settled["pending_finite_requests"], 0);
+ assert_eq!(settled["live_page_documents"], 1);
+ assert_eq!(settled["unowned_documents"], 0);
+ assert_eq!(settled["watch_refresh_settled"], true);
+ assert_eq!(settled["latest_call_reply_ns"], 50);
+
+ request(&mut rows, "held", "call", "page", "board", 70);
+ assert_eq!(snapshot(&rows)["pending_finite_requests"], 1);
+ let requests = snapshot(&rows)["remote_requests_excluding_next"].clone();
+ reply(&mut rows, "held", "value", "page", Some(80));
+ assert_eq!(snapshot(&rows)["remote_requests_excluding_next"], requests);
+ assert_eq!(snapshot(&rows)["live_page_documents"], 1);
+ request(&mut rows, "retire", "close_document", "page", "", 90);
+ assert_eq!(snapshot(&rows)["live_documents"], 1);
+ reply(&mut rows, "retire", "closed", "page", Some(100));
+ assert_eq!(snapshot(&rows)["live_documents"], 0);
+ }
+
+ #[test]
+ fn a_new_page_requires_its_own_watch_refresh_and_unclaimed_allocations_remain_visible() {
+ let mut rows = vec![];
+ page(&mut rows, "first", 10);
+ request(&mut rows, "watch", "open", "first", "board-changed", 20);
+ reply(&mut rows, "watch", "opened", "first", Some(30));
+ request(&mut rows, "refresh", "call", "first", "board", 40);
+ reply(&mut rows, "refresh", "value", "first", Some(50));
+ assert_eq!(snapshot(&rows)["watch_refresh_settled"], true);
+ request(&mut rows, "unclaimed", "open_document", "", "", 60);
+ reply(&mut rows, "unclaimed", "document", "orphan", Some(61));
+ assert_eq!(snapshot(&rows)["unowned_documents"], 1);
+ page(&mut rows, "second", 70);
+ assert_eq!(snapshot(&rows)["watch_refresh_settled"], false);
+ assert_eq!(snapshot(&rows)["live_page_documents"], 2);
+ }
+}
diff --git a/crates/cli/tests/integration/tui/board/performance/report.rs b/crates/cli/tests/integration/tui/board/performance/report.rs
new file mode 100644
index 0000000000..ef841dff8c
--- /dev/null
+++ b/crates/cli/tests/integration/tui/board/performance/report.rs
@@ -0,0 +1,253 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use sha2::{Digest, Sha256};
+use std::collections::BTreeMap;
+
+pub(super) struct Report {
+ file: Option<File>,
+ samples: Vec<Value>,
+ operations: &'static [&'static str],
+ pub smoke: bool,
+ pub failures: usize,
+}
+
+impl Report {
+ pub fn new() -> Self {
+ Self::scenario(
+ "idle Board; loaded transcript; no semantic RPC held pending",
+ &[
+ "input_insert",
+ "input_backspace",
+ "loaded_scroll_up",
+ "loaded_scroll_down",
+ "disclosure_open",
+ "disclosure_close",
+ ],
+ )
+ }
+
+ pub fn scenario(scenario: &str, operations: &'static [&'static str]) -> Self {
+ let smoke = match std::env::var("MAKA_TUI_PERF_MODE").as_deref() {
+ Err(std::env::VarError::NotPresent) | Ok("formal") => false,
+ Ok("smoke") => true,
+ other => panic!("MAKA_TUI_PERF_MODE must be formal or smoke: {other:?}"),
+ };
+ assert!(
+ smoke || !cfg!(debug_assertions),
+ "formal measurements require --release"
+ );
+ let file = std::env::var_os("MAKA_TUI_PERF_OUTPUT").map(|path| {
+ std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(path)
+ .expect("create a new raw performance JSONL file")
+ });
+ let mut report = Self {
+ file,
+ samples: Vec::new(),
+ operations,
+ smoke,
+ failures: 0,
+ };
+ let binary = std::fs::read(env!("CARGO_BIN_EXE_maka")).unwrap();
+ let cpu = std::fs::read_to_string("/proc/cpuinfo")
+ .ok()
+ .and_then(|text| {
+ text.lines()
+ .find(|line| line.starts_with("model name"))
+ .map(str::to_owned)
+ });
+ report.emit(json!({"kind":"metadata", "schema":3,
+ "mode":if smoke {"smoke"} else {"formal"},
+ "profile":if cfg!(debug_assertions) {"debug"} else {"release"},
+ "binary_sha256":format!("{:x}", Sha256::digest(&binary)),
+ "os":std::env::consts::OS, "arch":std::env::consts::ARCH, "cpu":cpu,
+ "viewport":{"columns":170,"rows":40},
+ "terminal":{"term":"xterm-256color","no_color":false},
+ "document_lifetime":"page; finite replies keep the document open",
+ "warmup_per_operation":report.warmup(), "samples_per_operation":report.count(),
+ "metric":"input_to_complete_pty_frame", "window_present_measured":false,
+ "scenario":scenario,
+ "data":{"history_blocks":300,"oversized_report_repetitions":1800},
+ "clock":"std::time::Instant; ns from per-case origin; process-local",
+ "cache_condition":"OS page cache uncontrolled; no drop_caches"}));
+ report
+ }
+
+ pub fn warmup(&self) -> usize {
+ if self.smoke { 2 } else { 20 }
+ }
+ pub fn count(&self) -> usize {
+ if self.smoke { 8 } else { 200 }
+ }
+
+ pub fn emit(&mut self, value: Value) {
+ let line = serde_json::to_string(&value).unwrap();
+ println!("{line}");
+ if let Some(file) = &mut self.file {
+ writeln!(file, "{line}").unwrap();
+ file.flush().unwrap();
+ }
+ }
+
+ pub fn sample(&mut self, rtt: u64, operation: &str, index: usize, mut sample: Value) -> bool {
+ sample["kind"] = json!("sample");
+ sample["added_rtt_ms"] = json!(rtt);
+ sample["operation"] = json!(operation);
+ sample["index"] = json!(index);
+ sample["warmup"] = json!(index < self.warmup());
+ let ok = sample["error"].is_null();
+ self.failures += usize::from(!ok);
+ self.emit(sample.clone());
+ self.samples.push(sample);
+ ok
+ }
+
+ pub fn rss(&mut self, rtt: u64, stage: &str, host_pid: u32, tui_pid: u32) {
+ for (process, pid) in [("host", host_pid), ("tui", tui_pid)] {
+ let status = std::fs::read_to_string(format!("/proc/{pid}/status"));
+ let value = match status {
+ Ok(text) => {
+ let read = |name: &str| {
+ text.lines()
+ .find(|line| line.starts_with(name))
+ .and_then(|line| line.split_whitespace().nth(1))
+ .and_then(|value| value.parse::<u64>().ok())
+ };
+ json!({"rss_kib":read("VmRSS:"),"hwm_kib":read("VmHWM:")})
+ }
+ Err(error) => json!({"error":error.to_string()}),
+ };
+ self.emit(json!({"kind":"memory", "added_rtt_ms":rtt, "stage":stage,
+ "process":process,"pid":pid,"value":value,
+ "scope":"point RSS and process-lifetime HWM; not a peak RSS sampler"}));
+ }
+ }
+
+ pub fn source(&mut self, rtt: u64, operation: &str, before: &Value, after: Value) {
+ let unchanged = ["viewReads", "activityViewReads", "pageReads", "updates"]
+ .into_iter()
+ .all(|key| before["stats"][key] == after["stats"][key]);
+ let no_rpc = ["remote_requests_excluding_next", "semantic_calls"]
+ .into_iter()
+ .all(|key| before["fence"][key] == after["fence"][key]);
+ self.emit(json!({"kind":"source_counts","added_rtt_ms":rtt,"operation":operation,
+ "before":before,"after":after,"local_counts_unchanged":unchanged,"no_local_rpc":no_rpc}));
+ if !unchanged || !no_rpc {
+ self.failures += 1;
+ }
+ }
+
+ pub fn wire(&mut self, rtt: u64, mut records: Vec<Value>) {
+ let mut requests = BTreeMap::new();
+ let mut calls = 0;
+ records.sort_by_key(|record| record["received_ns"].as_u64().unwrap_or(u64::MAX));
+ for mut record in records {
+ record["added_rtt_ms"] = json!(rtt);
+ if record["kind"] == "proxy_error" {
+ self.failures += 1;
+ } else if record["kind"] == "wire" {
+ let id = (
+ record["connection"].to_string(),
+ record["request_id"].to_string(),
+ );
+ if !record["request_id"].is_null() {
+ if record["direction"] == "client_to_host" {
+ requests.insert(id, record.clone());
+ } else if let Some(request) = requests.remove(&id) {
+ let ns = |value: &Value, key: &str| value[key].as_u64().unwrap();
+ let up = ns(&request, "forwarded_ns") - ns(&request, "received_ns");
+ let down = ns(&record, "forwarded_ns") - ns(&record, "received_ns");
+ self.emit(json!({"kind":"rpc_rtt", "added_rtt_ms":rtt,
+ "connection":record["connection"], "request_id":record["request_id"],
+ "operation":request["operation"], "remote_kind":request["remote_kind"],
+ "method":request["method"], "up_queue_ns":up, "down_queue_ns":down,
+ "observed_rtt_ns":ns(&record,"forwarded_ns") - ns(&request,"received_ns"),
+ "host_interval_ns":ns(&record,"received_ns") as i64 - ns(&request,"forwarded_ns") as i64}));
+ if up < rtt * 500_000 || down < rtt * 500_000 {
+ self.failures += 1;
+ }
+ if request["operation"] == "plugin.remote"
+ && request["remote_kind"] == "call"
+ {
+ calls += 1;
+ }
+ }
+ }
+ }
+ self.emit(record);
+ }
+ if calls == 0 {
+ self.failures += 1;
+ }
+ self.emit(
+ json!({"kind":"wire_summary","added_rtt_ms":rtt,"completed_remote_calls":calls,
+ "unreplied_requests_at_disconnect":requests.len()}),
+ );
+ }
+
+ pub fn finish(&mut self) {
+ let mut groups: BTreeMap<(u64, String), Vec<Value>> = BTreeMap::new();
+ for rtt in [0, 200] {
+ for &operation in self.operations {
+ groups.insert((rtt, operation.into()), Vec::new());
+ }
+ }
+ for sample in &self.samples {
+ if sample["warmup"] == false {
+ groups
+ .entry((
+ sample["added_rtt_ms"].as_u64().unwrap(),
+ sample["operation"].as_str().unwrap().into(),
+ ))
+ .or_default()
+ .push(sample.clone());
+ }
+ }
+ for ((rtt, operation), samples) in groups {
+ let mut durations: Vec<_> = samples
+ .iter()
+ .filter_map(|sample| sample["duration_ns"].as_u64())
+ .collect();
+ durations.sort_unstable();
+ let rank = |percent: usize| {
+ durations
+ .get((percent * durations.len()).div_ceil(100).saturating_sub(1))
+ .copied()
+ };
+ let failures = samples.len() - durations.len();
+ let complete = samples.len() == self.count() && failures == 0;
+ let pass = complete && rank(95).is_some_and(|ns| ns <= 50_000_000);
+ if !complete || (!self.smoke && !pass) {
+ self.failures += 1;
+ }
+ self.emit(json!({"kind":"summary", "metric":"input_to_complete_pty_frame",
+ "added_rtt_ms":rtt,"operation":operation,"attempted":samples.len(),
+ "expected":self.count(),"failed":failures,"unattempted":self.count()-samples.len(),
+ "successful_p50_ns":rank(50),"successful_p95_ns":rank(95),"successful_max_ns":durations.last(),
+ "quantile":"nearest-rank ceil(p*n)-1; failed attempts retained above",
+ "acceptance_pass":!self.smoke && pass}));
+ }
+ self.emit(json!({"kind":"result","failures":self.failures,"smoke":self.smoke,
+ "cold_start_measured":false,"fault_matrix_measured":false,"window_present_measured":false}));
+ }
+}
diff --git a/crates/cli/tests/integration/tui/board/performance/timed.rs b/crates/cli/tests/integration/tui/board/performance/timed.rs
new file mode 100644
index 0000000000..9fdeb23382
--- /dev/null
+++ b/crates/cli/tests/integration/tui/board/performance/timed.rs
@@ -0,0 +1,219 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+
+/// PTY receipt is observable; terminal-window presentation is not measured here.
+pub(super) struct Timed {
+ pub tui: Pty,
+ pub visual: Option<visual::Visual>,
+ origin: Instant,
+ tail: [u8; 8],
+ drawing: bool,
+ sequence: u64,
+ completed_ns: u64,
+ parsed_ns: u64,
+ chunks: Vec<Value>,
+}
+
+impl Timed {
+ pub fn new(tui: Pty, origin: Instant) -> Self {
+ Self {
+ tui,
+ visual: None,
+ origin,
+ tail: [0; 8],
+ drawing: false,
+ sequence: 0,
+ completed_ns: 0,
+ parsed_ns: 0,
+ chunks: Vec::new(),
+ }
+ }
+
+ pub fn screen(&self) -> String {
+ self.tui.screen.snapshot().unwrap().screen
+ }
+
+ pub fn find(&mut self, text: &str) {
+ let bytes = format!("\x06\x1b[200~{text}\x1b[201~");
+ assert!(self.input(bytes.as_bytes(), |s| s.contains("1/1"))["error"].is_null());
+ assert!(self.input(b"\x1b", |s| !s.contains("1/1") && s.contains(text))["error"].is_null());
+ }
+
+ pub fn settled(&mut self, proxy: &proxy::DelayProxy, after_watch: bool) -> Value {
+ let deadline = Instant::now() + Duration::from_secs(5);
+ loop {
+ self.read().expect("PTY while settling finite Remote work");
+ let mut fence = proxy.fence();
+ fence["observed_ns"] = json!(self.origin.elapsed().as_nanos() as u64);
+ fence["complete_frame_ns"] = json!(self.completed_ns);
+ fence["frame_seq"] = json!(self.sequence);
+ if fence["pending_finite_requests"] == 0
+ && fence["unowned_documents"] == 0
+ && (!after_watch || fence["watch_refresh_settled"] == true)
+ && self.completed_ns >= fence["latest_call_reply_ns"].as_u64().unwrap()
+ && self.tui.frames.ready()
+ && !self.screen().contains("Loading…")
+ {
+ return fence;
+ }
+ assert!(
+ Instant::now() < deadline,
+ "Remote work did not settle: {fence}; screen={}",
+ self.screen()
+ );
+ }
+ }
+
+ pub fn source(
+ &mut self,
+ runtime: &Runtime,
+ client: &maka_client::Client,
+ proxy: &proxy::DelayProxy,
+ ) -> Value {
+ let fence = self.settled(proxy, true);
+ let stats = runtime.block_on(remote(client, "activity-stats", Value::Null));
+ json!({"stats":stats,"fence":fence})
+ }
+
+ pub fn input(&mut self, bytes: &[u8], ready: impl Fn(&str) -> bool) -> Value {
+ self.observed(bytes, |timed| ready(&timed.screen()))
+ }
+
+ pub fn observed(&mut self, bytes: &[u8], ready: impl Fn(&Self) -> bool) -> Value {
+ let before = self.sequence;
+ self.chunks.clear();
+ let start = self.origin.elapsed().as_nanos() as u64;
+ self.tui.send(bytes);
+ let deadline = Instant::now() + Duration::from_secs(10);
+ let error = loop {
+ if let Err(error) = self.read() {
+ break Some(error);
+ }
+ if self.sequence > before && self.tui.frames.ready() && ready(self) {
+ break None;
+ }
+ if self.tui.child.try_wait().unwrap().is_some() {
+ break Some("TUI exited before expected complete frame".into());
+ }
+ if Instant::now() >= deadline {
+ break Some("expected complete frame timed out after 10 seconds".into());
+ }
+ };
+ json!({"metric":"input_to_complete_pty_frame", "start_ns":start,
+ "observed_end_ns":self.origin.elapsed().as_nanos() as u64,
+ "complete_frame_ns":self.completed_ns, "parsed_ns":self.parsed_ns,
+ "frame_seq_before":before, "frame_seq_after":self.sequence,
+ "duration_ns":error.is_none().then(|| self.completed_ns.saturating_sub(start)),
+ "error":error, "chunks":std::mem::take(&mut self.chunks)})
+ }
+
+ pub fn read(&mut self) -> Result<(), String> {
+ let mut poll = libc::pollfd {
+ fd: self.tui.master.as_ref().unwrap().as_raw_fd(),
+ events: libc::POLLIN,
+ revents: 0,
+ };
+ // poll wakes on readable bytes; 50 ms is a maximum idle wait, not a sleep.
+ let available = unsafe { libc::poll(&mut poll, 1, 50) };
+ if available < 0 {
+ return Err(std::io::Error::last_os_error().to_string());
+ }
+ if available == 0 {
+ return Ok(());
+ }
+ let mut bytes = [0; 8192];
+ let length = self
+ .tui
+ .master
+ .as_mut()
+ .unwrap()
+ .read(&mut bytes)
+ .map_err(|error| error.to_string())?;
+ if length == 0 {
+ return Err("PTY EOF".into());
+ }
+ let received_ns = self.origin.elapsed().as_nanos() as u64;
+ self.tui.frames.feed(&bytes[..length]);
+ for &byte in &bytes[..length] {
+ self.tail.copy_within(1.., 0);
+ self.tail[7] = byte;
+ if &self.tail == b"\x1b[?2026h" {
+ self.drawing = true;
+ } else if &self.tail == b"\x1b[?2026l" && self.drawing {
+ self.drawing = false;
+ self.sequence += 1;
+ self.completed_ns = received_ns;
+ }
+ }
+ self.tui.pending.extend_from_slice(&bytes[..length]);
+ let end = match std::str::from_utf8(&self.tui.pending) {
+ Ok(_) => self.tui.pending.len(),
+ Err(error) if error.error_len().is_none() => error.valid_up_to(),
+ Err(error) => return Err(format!("invalid terminal UTF-8: {error}")),
+ };
+ let text = std::str::from_utf8(&self.tui.pending[..end]).unwrap();
+ if let Some(visual) = &mut self.visual {
+ visual.write(text, self.tui.screen.size());
+ }
+ let reply = self
+ .tui
+ .screen
+ .write(text)
+ .map_err(|error| error.to_string())?;
+ self.tui.pending.drain(..end);
+ self.parsed_ns = self.origin.elapsed().as_nanos() as u64;
+ self.chunks
+ .push(json!({"bytes":length, "received_ns":received_ns,
+ "parsed_ns":self.parsed_ns, "frame_seq":self.sequence}));
+ if !reply.is_empty() {
+ self.tui.send(reply.as_bytes());
+ }
+ Ok(())
+ }
+}
+
+pub(super) fn field_character(screen: &str, row: usize, col: usize, expected: char) -> bool {
+ screen.lines().nth(row).is_some_and(|line| {
+ line.char_indices()
+ .find(|(byte, _)| line[..*byte].width() == col)
+ .is_some_and(|(_, character)| character == expected)
+ })
+}
+
+pub(super) fn position(screen: &str, text: &str) -> (usize, usize) {
+ screen
+ .lines()
+ .enumerate()
+ .find_map(|(row, line)| line.find(text).map(|byte| (row, line[..byte].width())))
+ .unwrap_or_else(|| panic!("missing {text:?}\n{screen}"))
+}
+
+pub(super) fn click(screen: &str, text: &str) -> Vec<u8> {
+ let (row, col) = position(screen, text);
+ format!(
+ "\x1b[<0;{};{}M\x1b[<0;{};{}m",
+ col + 1,
+ row + 1,
+ col + 1,
+ row + 1
+ )
+ .into_bytes()
+}
diff --git a/crates/cli/tests/integration/tui/board/performance/visual.rs b/crates/cli/tests/integration/tui/board/performance/visual.rs
new file mode 100644
index 0000000000..b165214670
--- /dev/null
+++ b/crates/cli/tests/integration/tui/board/performance/visual.rs
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use alacritty_terminal::{
+ Term,
+ event::VoidListener,
+ grid::Dimensions,
+ index::{Column, Line},
+ term::{Config, cell::Cell},
+ vte::ansi,
+};
+
+pub(super) struct Visual {
+ parser: ansi::Processor,
+ term: Term<VoidListener>,
+}
+
+struct Size(TerminalSize);
+impl Dimensions for Size {
+ fn columns(&self) -> usize {
+ self.0.cols().into()
+ }
+ fn screen_lines(&self) -> usize {
+ self.0.rows().into()
+ }
+ fn total_lines(&self) -> usize {
+ self.screen_lines()
+ }
+}
+
+impl Visual {
+ pub fn new(size: TerminalSize) -> Self {
+ Self {
+ parser: ansi::Processor::new(),
+ term: Term::new(Config::default(), &Size(size), VoidListener),
+ }
+ }
+
+ pub fn write(&mut self, mut text: &str, size: TerminalSize) {
+ if self.term.columns() != usize::from(size.cols())
+ || self.term.screen_lines() != usize::from(size.rows())
+ {
+ self.term.resize(Size(size));
+ }
+ // Match the existing Screen's UTF-8 boundary handling. The null listener
+ // emits no terminal replies or clipboard effects from this observer.
+ while !text.is_empty() {
+ let end = text.floor_char_boundary(256.min(text.len()));
+ self.parser.advance(&mut self.term, &text.as_bytes()[..end]);
+ self.parser.stop_sync(&mut self.term);
+ text = &text[end..];
+ }
+ }
+
+ pub fn cells(&self, row: usize, col: usize, length: usize) -> Vec<Cell> {
+ (col..col + length)
+ .map(|x| self.term.grid()[Line(row as i32)][Column(x)].clone())
+ .collect()
+ }
+}
+
+fn evidence(cells: &[Cell]) -> Value {
+ json!(
+ cells
+ .iter()
+ .map(|cell| json!({"text":cell.c.to_string(),
+ "foreground":format!("{:?}",cell.fg),"background":format!("{:?}",cell.bg),
+ "flags":format!("{:?}",cell.flags)}))
+ .collect::<Vec<_>>()
+ )
+}
+
+pub(super) fn drag(
+ timed: &mut timed::Timed,
+ proxy: &proxy::DelayProxy,
+ report: &mut report::Report,
+ rtt: u64,
+ hold: Option<&pending::gate::Hold>,
+) {
+ let (row, col) = timed::position(&timed.screen(), ANCHOR);
+ let baseline = timed.visual.as_ref().unwrap().cells(row, col, ANCHOR.len());
+ assert_eq!(
+ baseline.iter().map(|cell| cell.c).collect::<String>(),
+ ANCHOR
+ );
+ let selected = |timed: &timed::Timed, count: usize| {
+ let cells = timed
+ .visual
+ .as_ref()
+ .unwrap()
+ .cells(row, col, baseline.len());
+ cells
+ .iter()
+ .zip(&baseline)
+ .enumerate()
+ .all(|(index, (cell, original))| {
+ cell.c == original.c && (cell.bg != original.bg) == (index < count)
+ })
+ };
+ let press = format!(
+ "\x1b[<0;{};{}M\x1b[<32;{};{}M",
+ col + 1,
+ row + 1,
+ col + 5,
+ row + 1
+ );
+ let before_screen = timed.screen();
+ let mut action = || timed.observed(press.as_bytes(), |t| selected(t, 5));
+ let setup = match hold {
+ Some(hold) => hold.during(proxy, action),
+ None => action(),
+ };
+ report.emit(
+ json!({"kind":"drag_setup","stage":"press","added_rtt_ms":rtt,
+ "pending":hold.is_some(),"input_sgr":press,"sample":setup,
+ "screen_before":before_screen,"screen_after":timed.screen(),
+ "selection":{"row":row,"column":col,"selected_cells":5,
+ "baseline":evidence(&baseline),"before":evidence(&baseline),
+ "after":evidence(&timed.visual.as_ref().unwrap().cells(row,col,baseline.len()))}}),
+ );
+ assert!(
+ setup["error"].is_null(),
+ "press failure retained as drag_setup"
+ );
+ for index in 0..report.warmup() + report.count() {
+ for (suffix, count) in [("extend", 12), ("shrink", 5)] {
+ let before = timed
+ .visual
+ .as_ref()
+ .unwrap()
+ .cells(row, col, baseline.len());
+ let bytes = format!("\x1b[<32;{};{}M", col + count, row + 1);
+ let mut sample = || timed.observed(bytes.as_bytes(), |t| selected(t, count));
+ let mut value = match hold {
+ Some(hold) => hold.during(proxy, sample),
+ None => sample(),
+ };
+ value["selection"] = json!({"row":row,"column":col,"selected_cells":count,
+ "baseline":evidence(&baseline),"before":evidence(&before),
+ "after":evidence(&timed.visual.as_ref().unwrap().cells(row,col,baseline.len()))});
+ let operation = format!(
+ "{}_drag_{suffix}",
+ if hold.is_some() { "pending" } else { "idle" }
+ );
+ if !report.sample(rtt, &operation, index, value) {
+ return;
+ }
+ }
+ }
+ timed
+ .tui
+ .send(format!("\x1b[<0;{};{}m", col + 5, row + 1).as_bytes());
+ let before_screen = timed.screen();
+ let before = timed
+ .visual
+ .as_ref()
+ .unwrap()
+ .cells(row, col, baseline.len());
+ let mut action = || timed.observed(b"\x1b", |t| selected(t, 0));
+ let cleared = match hold {
+ Some(hold) => hold.during(proxy, action),
+ None => action(),
+ };
+ report.emit(
+ json!({"kind":"drag_setup","stage":"clear","added_rtt_ms":rtt,
+ "pending":hold.is_some(),"input_sgr":"\u{1b}","sample":cleared,
+ "screen_before":before_screen,"screen_after":timed.screen(),
+ "selection":{"row":row,"column":col,"selected_cells":0,
+ "baseline":evidence(&baseline),"before":evidence(&before),
+ "after":evidence(&timed.visual.as_ref().unwrap().cells(row,col,baseline.len()))}}),
+ );
+ assert!(
+ cleared["error"].is_null(),
+ "clear failure retained as drag_setup"
+ );
+}
diff --git a/crates/cli/tests/integration/tui/board/wire.rs b/crates/cli/tests/integration/tui/board/wire.rs
index 73861aaca7..820057bafb 100644
--- a/crates/cli/tests/integration/tui/board/wire.rs
+++ b/crates/cli/tests/integration/tui/board/wire.rs
@@ -44,6 +44,21 @@ pub(super) async fn preflight(client: &maka_client::Client) {
}
async fn bootstrap(client: &maka_client::Client, document: Uuid) -> Result<(), String> {
+ let mount = Uuid::new_v4();
+ let (app, target) = bind(client, "board", RemoteKind::Method).await?;
+ let initialized = exchange(
+ client,
+ RemoteRequest::Call {
+ binding: app,
+ target,
+ document,
+ input: serde_json::json!({"kind":"read", "route":null, "locale":"en"}),
+ },
+ )
+ .await?;
+ if !matches!(initialized, RemoteResult::Value { .. }) {
+ return Err(format!("app initialization returned {initialized:?}"));
+ }
let (read, read_target) = bind(client, "board-activity.read", RemoteKind::Method).await?;
let (stream, stream_target) = bind(client, "board-activity.stream", RemoteKind::Stream).await?;
if (
@@ -62,6 +77,7 @@ async fn bootstrap(client: &maka_client::Client, document: Uuid) -> Result<(), S
target: stream_target,
document,
input: serde_json::to_value(wire::Open {
+ mount,
resource: "board-activity".into(),
route: Value::Null,
locale: "en".into(),
@@ -89,6 +105,7 @@ async fn bootstrap(client: &maka_client::Client, document: Uuid) -> Result<(), S
return Err(format!("initial event is not Ready: {event:?}"));
};
let mut request = wire::Read {
+ mount,
resource: "board-activity".into(),
fence,
direction: wire::Direction::Tail,
diff --git a/crates/cli/tests/integration/tui/branch.rs b/crates/cli/tests/integration/tui/branch.rs
index a7de5a71b9..6c2e3e72c4 100644
--- a/crates/cli/tests/integration/tui/branch.rs
+++ b/crates/cli/tests/integration/tui/branch.rs
@@ -164,7 +164,7 @@ fn branch_preserves_selected_history_and_draft_and_reopens_by_original_receipt()
assert!(saved["branch"].is_null());
let cursor = saved["navigation"]["cursor"].as_u64().unwrap() as usize;
assert_eq!(
- saved["navigation"]["entries"][cursor]["id"],
+ saved["navigation"]["entries"][cursor]["route"]["id"],
request.target_session_id
);
let receipt = runtime
diff --git a/crates/cli/tests/integration/tui/builtins.rs b/crates/cli/tests/integration/tui/builtins.rs
index 47b7c5b53b..3341de25f4 100644
--- a/crates/cli/tests/integration/tui/builtins.rs
+++ b/crates/cli/tests/integration/tui/builtins.rs
@@ -137,15 +137,8 @@ async fn remote(client: &maka_client::Client, package: &str, method: &str, input
fn category(tui: &mut Pty, name: &str, ready: &str) {
tui.wait_for("Settings");
tui.click_text("Settings");
- // Settings uses a fixed 14-column category list, even in a wide terminal.
- let shown = match name {
- "Usage & pricing" => "Usage & prici…",
- "Conversation import" => "Conversation …",
- "External agents" => "External agen…",
- _ => name,
- };
- tui.wait_for(shown);
- tui.click_text(shown);
+ tui.wait_for(name);
+ tui.click_text(name);
tui.wait_for(ready);
}
diff --git a/crates/cli/tests/integration/tui/builtins/conversations.rs b/crates/cli/tests/integration/tui/builtins/conversations.rs
index a054b16f60..1dfbbe4d38 100644
--- a/crates/cli/tests/integration/tui/builtins/conversations.rs
+++ b/crates/cli/tests/integration/tui/builtins/conversations.rs
@@ -127,7 +127,10 @@ fn real_host_import_and_recall_open_the_exact_imported_session() {
.join("default/state.json");
let saved: Value = serde_json::from_slice(&std::fs::read(checkpoint).unwrap()).unwrap();
let cursor = saved["navigation"]["cursor"].as_u64().unwrap() as usize;
- assert_eq!(saved["navigation"]["entries"][cursor]["id"], imported);
+ assert_eq!(
+ saved["navigation"]["entries"][cursor]["route"]["id"],
+ imported
+ );
fixture.client.disconnect();
let mut host = fixture.host;
host.retire_registered();
diff --git a/crates/cli/tests/integration/tui/cold_startup.rs b/crates/cli/tests/integration/tui/cold_startup.rs
new file mode 100644
index 0000000000..9ac3e1312a
--- /dev/null
+++ b/crates/cli/tests/integration/tui/cold_startup.rs
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::super::candidate::CandidateFixture;
+use super::fault_acceptance::{fixture, measure};
+use super::*;
+use maka_protocol::Operation;
+use serde_json::{Value, json};
+use sha2::{Digest, Sha256};
+use tokio::runtime::Runtime;
+
+mod cases;
+mod lifecycle;
+mod page;
+
+const PACKAGE: &str = "example.acceptance-startup";
+const TITLE: &str = "Startup lab";
+const METRICS: [&str; 5] = [
+ "first_shell_frame_ns",
+ "host_ready_observed_ns",
+ "target_content_frame_ns",
+ "target_interactive_frame_ns",
+ "tui_attachment_observed_ns",
+];
+
+/// Process startup, with the OS page cache deliberately uncontrolled.
+#[test]
+#[ignore = "real Host+PTY process startup; serial fixed release binary; raw JSONL"]
+fn process_startup_reports_attach_on_demand_native_and_installed_js() {
+ assert!(
+ std::path::Path::new("/proc/self/status").is_file(),
+ "this observer requires Linux /proc"
+ );
+ let mut log = measure::Log::new("process_startup");
+ let hash = || {
+ format!(
+ "{:x}",
+ Sha256::digest(std::fs::read(env!("CARGO_BIN_EXE_maka")).unwrap())
+ )
+ };
+ let before = hash();
+ let count = if log.smoke { 2 } else { 20 };
+ let groups = [
+ ("attach", "native"),
+ ("on_demand", "native"),
+ ("attach", "installed_js"),
+ ("on_demand", "installed_js"),
+ ("attach", "empty"),
+ ("on_demand", "empty"),
+ ];
+ log.emit(json!({"kind":"startup_contract", "samples_per_group":count,
+ "metric":"Pty::spawn call to complete parsed synchronized frame; upper bound",
+ "origin":"immediately before Pty::spawn, including openpty and Command setup",
+ "host_ready":"independent HostStatus observation; attach is a reobservation of preexisting readiness",
+ "target_interactive":"target content followed by a demonstrated local control change",
+ "tui_attachment":"separate loaded-catalog frame observation when available; native local interaction may precede attachment; no attachment wait before early detach",
+ "checkpoint":"four restored-route groups; two empty controls without a fake JS-empty duplicate",
+ "comparison":"restored native and JS both preinstall the same Shared JS fixture",
+ "data":{"sessions":0,"js_blocks":1,"js_logical_lines":60},
+ "preparation":"plugin installation and checkpoint seeding precede t0",
+ "os_release":std::fs::read_to_string("/proc/sys/kernel/osrelease").ok(),
+ "disk_cache_cold":false,"window_present_measured":false}));
+ let mut samples: Vec<Vec<Value>> = groups.iter().map(|_| Vec::new()).collect();
+ // Interleave groups to avoid assigning every late-run cache condition to JS.
+ for iteration in 0..count {
+ for (index, (host, target)) in groups.iter().enumerate() {
+ log.context = json!({"host":host,"target":target,"iteration":iteration,
+ "checkpoint":if *target == "empty" {"empty"} else {"restored_route"}});
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ cases::run(host, target, &mut log)
+ }));
+ let mut row = match result {
+ Ok(value) => value,
+ Err(error) => {
+ log.failures += 1;
+ let message = error
+ .downcast_ref::<String>()
+ .map(String::as_str)
+ .or_else(|| error.downcast_ref::<&str>().copied())
+ .unwrap_or("non-string panic");
+ json!({"error":message,"normal_cleanup":false,
+ "fallback_cleanup_possible":true})
+ }
+ };
+ row["kind"] = json!("startup_sample");
+ for metric in METRICS {
+ if row.get(metric).is_none() {
+ row[metric] = Value::Null;
+ }
+ }
+ log.emit(row.clone());
+ samples[index].push(row);
+ }
+ }
+ for ((host, target), rows) in groups.iter().zip(samples) {
+ log.context = json!({"host":host,"target":target,
+ "checkpoint":if *target == "empty" {"empty"} else {"restored_route"}});
+ for metric in METRICS {
+ let mut values: Vec<u64> = rows.iter().filter_map(|row| row[metric].as_u64()).collect();
+ values.sort_unstable();
+ let percentile = |percent: usize| {
+ values
+ .get((values.len() * percent).div_ceil(100).saturating_sub(1))
+ .copied()
+ };
+ log.emit(json!({"kind":"startup_summary","metric":metric,"total":rows.len(),
+ "measured":values.len(),"missing":rows.len()-values.len(),
+ "failed_cases":rows.iter().filter(|row| !row["error"].is_null()).count(),
+ "p50_ns":percentile(50),"p95_ns":percentile(95),"max_ns":values.last(),
+ "percentile":"nearest-rank over observed durations; all missing/failed cases retained"}));
+ }
+ }
+ log.context = json!({});
+ let after = hash();
+ if before != after {
+ log.failures += 1;
+ }
+ log.emit(json!({"kind":"suite_end","failures":log.failures,
+ "binary_sha256_before":before,"binary_sha256_after":after,
+ "absolute_startup_threshold":"not specified by the design; descriptive measurement"}));
+ assert_eq!(
+ log.failures, 0,
+ "startup, interaction and normal drain must all succeed"
+ );
+}
diff --git a/crates/cli/tests/integration/tui/cold_startup/cases.rs b/crates/cli/tests/integration/tui/cold_startup/cases.rs
new file mode 100644
index 0000000000..9abc18bc41
--- /dev/null
+++ b/crates/cli/tests/integration/tui/cold_startup/cases.rs
@@ -0,0 +1,194 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+
+pub(super) fn run(host_mode: &str, target: &str, log: &mut measure::Log) -> Value {
+ let directory = tempfile::tempdir().unwrap();
+ let mut host = CandidateFixture::new(directory.path().join("root"));
+ let runtime = Runtime::new().unwrap();
+ let failures_before = log.failures;
+ let checkpoint = directory
+ .path()
+ .join("tui-state")
+ .join(&host.root_id)
+ .join("startup/state.json");
+ let mut preexisting_ready = Value::Null;
+ if target != "empty" || host_mode == "attach" {
+ fixture::serve(&mut host);
+ let (client, notices) = runtime.block_on(fixture::connect(&host.root));
+ if target != "empty" {
+ runtime.block_on(fixture::install(
+ &client,
+ directory.path(),
+ PACKAGE,
+ TITLE,
+ "shared",
+ ));
+ page::restored_checkpoint(&host, target);
+ let resources = runtime.block_on(lifecycle::resources(&client));
+ log.emit(json!({"kind":"preparation_resources","value":resources}));
+ assert_eq!(resources["success"], true);
+ assert!(checkpoint.is_file());
+ }
+ preexisting_ready = runtime
+ .block_on(client.request(Operation::HostStatus, json!({})))
+ .unwrap();
+ assert_eq!(preexisting_ready["state"], "ready");
+ runtime.block_on(lifecycle::disconnect(client, notices));
+ if host_mode == "on_demand" {
+ let drained = lifecycle::retire(&mut host);
+ log.emit(json!({"kind":"preparation_host_drain","value":drained}));
+ assert_eq!(drained["success"], true);
+ preexisting_ready = Value::Null;
+ }
+ }
+ let checkpoint_bytes = std::fs::read(&checkpoint).ok();
+ assert_eq!(checkpoint_bytes.is_some(), target != "empty");
+ if host_mode == "on_demand" {
+ assert!(!host.registration.exists());
+ assert!(
+ maka_event_log::root::RootOwner::open(
+ &host.root,
+ &maka_event_log::root::RootNamespaces::for_current_account().unwrap()
+ )
+ .is_ok()
+ );
+ }
+ log.emit(json!({"kind":"prepared", "root_id":host.root_id,
+ "root_condition":"fresh synthetic Root, initialized before timing",
+ "checkpoint_bytes":checkpoint_bytes.as_ref().map(Vec::len),
+ "checkpoint_sha256":checkpoint_bytes.map(|bytes| format!("{:x}", Sha256::digest(bytes))),
+ "host_ready_before_spawn":preexisting_ready,
+ "data":{"sessions":0,"js_blocks":usize::from(target != "empty"),
+ "js_logical_lines":if target == "empty" {0} else {60}},
+ "installed_fixture":target != "empty","preparation_excluded":true}));
+
+ // The existing fixture does not expose Command::spawn internally. Include
+ // its small PTY/setup cost explicitly rather than inventing an exec time.
+ let origin = Instant::now();
+ let mut tui = page::spawn(&host);
+ let mut meter = measure::Meter::new(origin);
+ let spawn_returned = meter.now();
+ let observer = runtime.spawn(lifecycle::observe(host.root.clone(), origin));
+ log.emit(
+ json!({"kind":"spawn","start_ns":0,"returned_ns":spawn_returned,"tui_pid":tui.child.id()}),
+ );
+ let shell = meter.wait(&mut tui, 0, 0, |screen| {
+ screen.contains("New session") && screen.contains("Settings")
+ });
+ log.sample("spawn_to_first_shell_frame", shell.clone());
+ let content =
+ if shell["error"].is_null() && page::ready(shell["frame"].as_str().unwrap(), target) {
+ let mut value = shell.clone();
+ value["already_in_first_shell_frame"] = json!(true);
+ value
+ } else if shell["error"].is_null() {
+ meter.wait(&mut tui, 0, 0, |screen| page::ready(screen, target))
+ } else {
+ json!({"error":"shell unavailable","duration_ns":null})
+ };
+ log.sample("spawn_to_target_content", content.clone());
+ let interaction = if content["error"].is_null() {
+ page::interact(&mut tui, &mut meter, target)
+ } else {
+ json!({"error":"target content unavailable","complete_frame_parsed_ns":null})
+ };
+ let interactive_ns = if interaction["error"].is_null() {
+ interaction["complete_frame_parsed_ns"].clone()
+ } else {
+ Value::Null
+ };
+ log.sample("target_local_interaction", interaction.clone());
+ let (mut ready, connection) = runtime.block_on(observer).unwrap();
+ ready["ready_before_spawn"] = json!(host_mode == "attach");
+ ready["interpretation"] = json!(if host_mode == "attach" {
+ "post-spawn status reobservation; Host was already ready before t0"
+ } else {
+ "upper bound on on-demand Host readiness; probe is not the internal transition"
+ });
+ if !ready["error"].is_null() {
+ log.failures += 1;
+ }
+ log.emit(ready.clone());
+ log.rss("target_observed", "tui", tui.child.id());
+ if let Some(pid) = ready["pid"].as_u64() {
+ log.rss("target_observed", "host", pid as u32);
+ }
+
+ if interaction["error"].is_null() {
+ page::reset_interaction(&mut tui, target);
+ }
+ // Inspect frames already observed, without waiting for attachment and hiding
+ // the valid early-detach boundary exercised by restored native Settings.
+ let current_frame = tui.screen.snapshot().unwrap().screen;
+ let attachment_ns = [&shell, &content, &interaction]
+ .into_iter()
+ .find(|row| row["error"].is_null() && row["frame"].as_str().is_some_and(page::attached))
+ .map(|row| row["complete_frame_parsed_ns"].clone())
+ .or_else(|| {
+ (tui.frames.ready() && page::attached(¤t_frame)).then(|| json!(meter.now()))
+ })
+ .unwrap_or(Value::Null);
+ log.emit(json!({"kind":"tui_attachment_observation","observed_ns":attachment_ns,
+ "observed":!attachment_ns.is_null(),
+ "basis":"complete frame with loaded empty Session catalog; not an internal handshake timestamp",
+ "waited_for_attachment":false}));
+ let captured = maka_client::local::read_discovery(&host.root).unwrap();
+ assert_eq!(captured.root_id, host.root_id);
+ if let Some((client, _)) = &connection {
+ assert_eq!(captured.root_id, client.identity.root_id);
+ assert_eq!(captured.host_epoch, client.identity.host_epoch);
+ }
+ log.emit(
+ json!({"kind":"host_before_ui_close","root_id":captured.root_id,
+ "host_epoch":captured.host_epoch,"pid":captured.pid.get()}),
+ );
+ let close_start = meter.now();
+ lifecycle::close(&mut tui);
+ log.emit(json!({"kind":"normal_ui_close","start_ns":close_start,"end_ns":meter.now(),"success":true}));
+ if let Some((client, notices)) = connection {
+ if target != "empty" {
+ let resources = runtime.block_on(lifecycle::resources(&client));
+ if resources["success"] != true {
+ log.failures += 1;
+ }
+ log.emit(json!({"kind":"closed_resources","value":resources}));
+ }
+ runtime.block_on(lifecycle::disconnect(client, notices));
+ } else if target != "empty" {
+ log.failures += 1;
+ log.emit(
+ json!({"kind":"closed_resources","error":"Host observer unavailable","value":null}),
+ );
+ }
+ let drained = lifecycle::retire_observed(&mut host, &captured);
+ if drained["success"] != true {
+ log.failures += 1;
+ }
+ log.emit(drained.clone());
+ json!({"first_shell_frame_ns":shell["duration_ns"],
+ "host_ready_observed_ns":ready["observed_ns"],
+ "target_content_frame_ns":content["duration_ns"],
+ "target_interactive_frame_ns":interactive_ns,
+ "tui_attachment_observed_ns":attachment_ns,
+ "interaction_sent_ns":interaction["start_ns"],
+ "host_ready_internal_event_ns":null,"normal_cleanup":drained["success"],
+ "error":(log.failures != failures_before).then_some("one or more observations or drains failed")})
+}
diff --git a/crates/cli/tests/integration/tui/cold_startup/lifecycle.rs b/crates/cli/tests/integration/tui/cold_startup/lifecycle.rs
new file mode 100644
index 0000000000..3718662e59
--- /dev/null
+++ b/crates/cli/tests/integration/tui/cold_startup/lifecycle.rs
@@ -0,0 +1,194 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use maka_client::Client;
+use maka_client::local::{Discovery, open_stream, read_discovery};
+use maka_event_log::root::{RootNamespaces, RootOwner};
+use std::path::{Path, PathBuf};
+use tokio::task::JoinHandle;
+
+pub(super) async fn disconnect(client: Client, notices: JoinHandle<()>) {
+ client.disconnect();
+ client.closed().await;
+ notices.await.unwrap();
+}
+
+/// This observer never starts a Host: only the measured TUI may launch it.
+pub(super) async fn observe(
+ root: PathBuf,
+ origin: Instant,
+) -> (Value, Option<(Client, JoinHandle<()>)>) {
+ let now = || origin.elapsed().as_nanos() as u64;
+ let started = now();
+ let deadline = Instant::now() + Duration::from_secs(12);
+ let mut attempts = Vec::new();
+ loop {
+ let start = now();
+ let result = tokio::time::timeout(Duration::from_secs(2), connect_once(&root)).await;
+ let result = result.unwrap_or_else(|_| Err("discovery/handshake timeout".into()));
+ match result {
+ Ok((client, notices, pid)) => {
+ let response = tokio::time::timeout(
+ Duration::from_secs(2),
+ client.request(Operation::HostStatus, json!({})),
+ )
+ .await;
+ let end = now();
+ let ready = matches!(&response, Ok(Ok(value)) if value["state"] == "ready");
+ attempts.push(json!({"start_ns":start,"end_ns":end,
+ "response":format!("{response:?}"),"ready":ready}));
+ if ready {
+ return (
+ json!({"kind":"host_ready_observation","observer_started_ns":started,
+ "observed_ns":end,"pid":pid,"root_id":client.identity.root_id,
+ "host_epoch":client.identity.host_epoch,
+ "requested_retry_interval_ns":1_000_000,"attempts":attempts,
+ "internal_ready_event_ns":null,"error":null}),
+ Some((client, notices)),
+ );
+ }
+ disconnect(client, notices).await;
+ }
+ Err(error) => attempts.push(json!({"start_ns":start,"end_ns":now(),"error":error})),
+ }
+ if Instant::now() >= deadline {
+ return (
+ json!({"kind":"host_ready_observation","observer_started_ns":started,
+ "observed_ns":null,"attempts":attempts,"error":"ready observation timeout"}),
+ None,
+ );
+ }
+ tokio::time::sleep(Duration::from_millis(1)).await;
+ }
+}
+
+async fn connect_once(root: &Path) -> Result<(Client, JoinHandle<()>, u32), String> {
+ let discovery = read_discovery(root).map_err(|error| error.to_string())?;
+ let stream = open_stream(&discovery.endpoint)
+ .await
+ .map_err(|error| error.to_string())?;
+ let (client, mut notices) = Client::connect(
+ stream,
+ &discovery.root_id,
+ &discovery.host_epoch,
+ maka_client::Operations,
+ )
+ .await
+ .map_err(|error| error.to_string())?;
+ let pump = tokio::spawn(async move { while notices.recv().await.is_some() {} });
+ Ok((client, pump, discovery.pid.get()))
+}
+
+pub(super) fn close(tui: &mut Pty) {
+ tui.filter_command("Close interface only");
+ tui.click_text("Close interface only");
+ tui.finish();
+}
+
+pub(super) async fn resources(client: &Client) -> Value {
+ let deadline = Instant::now() + Duration::from_secs(8);
+ loop {
+ let result = fixture::remote(client, PACKAGE, "stats", Value::Null).await;
+ if result.as_ref().map_or(true, |stats| stats["active"] == 0) || Instant::now() >= deadline
+ {
+ return match result {
+ Ok(stats) => {
+ json!({"success":stats["active"] == 0 && stats["closed"] == stats["opened"],"stats":stats})
+ }
+ Err(error) => json!({"success":false,"error":error}),
+ };
+ }
+ tokio::time::sleep(Duration::from_millis(1)).await;
+ }
+}
+
+/// Retirement targets this fixture's exact live epoch, never a historical PID.
+pub(super) fn retire(host: &mut CandidateFixture) -> Value {
+ let discovery = read_discovery(&host.root).unwrap();
+ retire_observed(host, &discovery)
+}
+
+pub(super) fn retire_observed(host: &mut CandidateFixture, discovery: &Discovery) -> Value {
+ let origin = Instant::now();
+ assert_eq!(discovery.root_id, host.root_id);
+ let current = read_discovery(&host.root);
+ if !current.as_ref().is_ok_and(|current| {
+ current.root_id == discovery.root_id
+ && current.host_epoch == discovery.host_epoch
+ && current.pid == discovery.pid
+ }) {
+ return json!({"kind":"host_drain","success":false,
+ "expected_root_id":discovery.root_id,"expected_host_epoch":discovery.host_epoch,
+ "pid":discovery.pid.get(),"retire_attempted":false,
+ "error":current.err().map_or_else(
+ || "observed Host identity changed before retirement".to_owned(),
+ |error| format!("observed Host disappeared before retirement: {error}"))});
+ }
+ let result = Command::new(env!("CARGO_BIN_EXE_maka"))
+ .args(["host", "retire", "--root"])
+ .arg(&host.root)
+ .args(["--expected-host-epoch", &discovery.host_epoch])
+ .output()
+ .unwrap();
+ let mut child_exit = None;
+ let deadline = Instant::now() + Duration::from_secs(10);
+ let mut observations = Vec::new();
+ loop {
+ if let Some(child) = &mut host.child
+ && let Some(status) = child.try_wait().unwrap()
+ {
+ child_exit = Some(status.success());
+ host.child.take();
+ }
+ let released =
+ RootOwner::open(&host.root, &RootNamespaces::for_current_account().unwrap()).is_ok();
+ let state = process_state(discovery.pid.get());
+ let exited = matches!(state.as_deref(), None | Some("Z"));
+ let gone = !host.registration.exists();
+ observations.push(json!({"at_ns":origin.elapsed().as_nanos() as u64,
+ "registration_removed":gone,"root_released":released,"process_state":state}));
+ if (released && gone && exited && host.child.is_none()) || Instant::now() >= deadline {
+ return json!({"kind":"host_drain","duration_ns":origin.elapsed().as_nanos() as u64,
+ "success":result.status.success() && released && gone && exited && child_exit != Some(false),
+ "retire_exit_success":result.status.success(),"host_child_exit_success":child_exit,
+ "pid":discovery.pid.get(),"expected_host_epoch":discovery.host_epoch,
+ "host_exit_status":"only available for the directly owned attach/preparation child",
+ "registration_removed":gone,"root_released":released,"process_exited":exited,
+ "process_reaped":state.is_none(),"observations":observations,
+ "retire_stdout":String::from_utf8_lossy(&result.stdout),
+ "retire_stderr":String::from_utf8_lossy(&result.stderr)});
+ }
+ std::thread::sleep(Duration::from_millis(1));
+ }
+}
+
+fn process_state(pid: u32) -> Option<String> {
+ // A zombie has exited, but is reported separately from a reaped process.
+ match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
+ Ok(stat) => Some(
+ stat.rsplit_once(") ")
+ .and_then(|(_, rest)| rest.split_whitespace().next())
+ .unwrap_or("unparseable")
+ .to_owned(),
+ ),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
+ Err(error) => Some(format!("unavailable: {error}")),
+ }
+}
diff --git a/crates/cli/tests/integration/tui/cold_startup/page.rs b/crates/cli/tests/integration/tui/cold_startup/page.rs
new file mode 100644
index 0000000000..cf09b28f56
--- /dev/null
+++ b/crates/cli/tests/integration/tui/cold_startup/page.rs
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+
+pub(super) fn restored_checkpoint(host: &CandidateFixture, target: &str) {
+ let mut tui = spawn(host);
+ tui.wait_for("No sessions yet");
+ if target == "installed_js" {
+ tui.wait_for(TITLE);
+ tui.click_text(TITLE);
+ tui.wait_for("Last-good acceptance content");
+ tui.wait_for("Retained record 059");
+ } else {
+ tui.command("Open settings");
+ tui.wait_for("Appearance");
+ tui.click_page_text("Appearance");
+ tui.wait_for("Maka dark ▾");
+ }
+ lifecycle::close(&mut tui);
+}
+
+pub(super) fn spawn(host: &CandidateFixture) -> Pty {
+ Pty::spawn(&[
+ "--root",
+ host.root.to_str().unwrap(),
+ "--profile",
+ "startup",
+ ])
+}
+
+pub(super) fn ready(screen: &str, target: &str) -> bool {
+ match target {
+ "native" => screen.contains("Maka dark ▾"),
+ "installed_js" => {
+ screen.contains("Last-good acceptance content")
+ && screen.contains("Local draft")
+ && screen.contains("Retained record 059")
+ && !screen.contains("Loading…")
+ }
+ "empty" => {
+ // An empty checkpoint opens Home, not a session composer. Its
+ // central New action is rendered only after Host connection.
+ screen.contains("No sessions yet")
+ && screen
+ .lines()
+ .any(|line| line.contains("New session") && line.contains("Ctrl+N"))
+ }
+ _ => unreachable!(),
+ }
+}
+
+/// Positive connection evidence from a rendered, loaded catalog; native local
+/// controls alone deliberately do not imply that the TUI attached to its Host.
+pub(super) fn attached(screen: &str) -> bool {
+ screen.contains("No sessions yet")
+ && !screen.contains("connecting")
+ && !screen.contains("connection failed")
+ && !screen.contains("not connected")
+}
+
+pub(super) fn interact(tui: &mut Pty, meter: &mut measure::Meter, target: &str) -> Value {
+ let screen = tui.screen.snapshot().unwrap().screen;
+ let (bytes, observed, operation) = match target {
+ "native" => (
+ fixture::click(&screen, "Maka dark ▾"),
+ "○ Dusk",
+ "native_palette_chooser",
+ ),
+ "installed_js" => {
+ let mut bytes = fixture::click(&screen, "Local draft");
+ bytes.extend_from_slice(b"startupprobe");
+ (bytes, "startupprobe", "js_local_draft_edit")
+ }
+ "empty" => (
+ b"\x10startupprobe".to_vec(),
+ "startupprobe",
+ "native_shell_palette_input",
+ ),
+ _ => unreachable!(),
+ };
+ assert!(!screen.contains(observed), "interaction marker must be new");
+ let mut row = meter.input(tui, &bytes, |screen| screen.contains(observed));
+ row["control"] = json!(operation);
+ row["metric"] = json!("input_to_complete_pty_frame_parsed_upper_bound");
+ row
+}
+
+pub(super) fn reset_interaction(tui: &mut Pty, target: &str) {
+ if target == "installed_js" {
+ tui.send(b"\x01\x7f");
+ tui.wait_until(|screen| !screen.contains("startupprobe") && screen.contains("Local draft"));
+ } else {
+ tui.send(b"\x1b");
+ tui.wait_until(|screen| !screen.contains("○ Dusk") && !screen.contains("startupprobe"));
+ }
+}
diff --git a/crates/cli/tests/integration/tui/connections.rs b/crates/cli/tests/integration/tui/connections.rs
index 985ef36d2e..91b56822f2 100644
--- a/crates/cli/tests/integration/tui/connections.rs
+++ b/crates/cli/tests/integration/tui/connections.rs
@@ -90,8 +90,30 @@ fn connections_directory_pages_reopens_and_manages_fixed_targets_with_cas() {
);
tui.click_text("Updated elsewhere");
tui.wait_for("› Updated elsewhere");
- // Keyboard goes from List to Add, Default model, Rename, Previous.
- tui.send(b"\t\t\t\t\r");
+ // Follow the visible keyboard hint through the shared header controls.
+ // Sidebar and Back are now real focus stops; ordinals are not identities.
+ for _ in 0..16 {
+ let screen = tui.screen.snapshot().unwrap().screen;
+ let hint = screen.lines().last().unwrap_or("").trim().to_owned();
+ if hint == "Previous page" {
+ break;
+ }
+ tui.send(b"\t");
+ tui.wait_until(|screen| screen.lines().last().unwrap_or("").trim() != hint);
+ }
+ assert_eq!(
+ tui.screen
+ .snapshot()
+ .unwrap()
+ .screen
+ .lines()
+ .last()
+ .unwrap_or("")
+ .trim(),
+ "Previous page",
+ "previous page is reachable by keyboard"
+ );
+ tui.send(b"\r");
tui.wait_for("Directory 00");
tui.send(b"\x1b"); // Back to Settings, not a fabricated connection/session route.
tui.wait_until(|s| s.lines().next().is_some_and(|l| l.contains("Settings")));
diff --git a/crates/cli/tests/integration/tui/extensions.rs b/crates/cli/tests/integration/tui/extensions.rs
index ef80215e6e..fd299b627f 100644
--- a/crates/cli/tests/integration/tui/extensions.rs
+++ b/crates/cli/tests/integration/tui/extensions.rs
@@ -160,13 +160,13 @@ fn plugin_form_saves_in_place_and_preserves_a_stale_draft_without_model_executio
tui.wait_for("━●");
tui.filter_command("Discard draft and reopen");
tui.click_text("Discard draft and reopen");
- // Discarding reopens the app where it starts, not the directory.
- tui.wait_until(|screen| screen.contains("Plugin fixture 000") && !screen.contains("Draft"));
+ // Discard only resets this address; it must not navigate to the first route.
+ tui.wait_until(|screen| screen.contains("Plugin fixture 008") && !screen.contains("Draft"));
tui.close_terminal();
tui.finish();
// The app is a place of its own: a restart returns to it and reads it fresh.
let mut reopened = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
- reopened.wait_for("Plugin fixture 000");
+ reopened.wait_for("Plugin fixture 008");
assert!(
!reopened
.screen
@@ -175,7 +175,13 @@ fn plugin_form_saves_in_place_and_preserves_a_stale_draft_without_model_executio
.screen
.contains("Disconnected")
);
- reopened.wait_for("Next");
+ reopened.wait_for("Enabled");
+ reopened.wait_for("Pinned");
+ assert_eq!(
+ runtime.block_on(catalog(&client))["revision"],
+ merged["revision"],
+ "restoring the exact 008 form must not write preferences"
+ );
reopened.close_terminal();
reopened.finish();
runtime.block_on(async {
diff --git a/crates/cli/tests/integration/tui/fault_acceptance.rs b/crates/cli/tests/integration/tui/fault_acceptance.rs
new file mode 100644
index 0000000000..6bed75af5e
--- /dev/null
+++ b/crates/cli/tests/integration/tui/fault_acceptance.rs
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::super::candidate::CandidateFixture;
+use super::*;
+use maka_protocol::{
+ Operation,
+ plugin::{RemoteBinding, RemoteRequest, RemoteResult},
+};
+use serde_json::{Value, json};
+use tokio::runtime::Runtime;
+
+mod cases;
+pub(super) mod fixture;
+pub(super) mod measure;
+mod mutation;
+
+/// Run serially against one fixed binary. Smoke validates the harness only.
+#[test]
+#[ignore = "real Host+PTY fault matrix; serial; emits raw JSONL"]
+fn presenter_faults_preserve_shell_and_report_actual_vm_scope() {
+ let mut log = measure::Log::new("fault");
+ let modes: &[&str] = if log.smoke {
+ &[
+ "duplicate",
+ "oversized",
+ "cooperative_pending",
+ "runaway_read",
+ ]
+ } else {
+ &[
+ "runaway_read",
+ "runaway_submit",
+ "cooperative_pending",
+ "never_settle",
+ "invalid",
+ "duplicate",
+ "oversized",
+ "overflow",
+ ]
+ };
+ for vm in ["shared", "dedicated"] {
+ for mode in modes {
+ cases::run(vm, mode, &mut log);
+ }
+ }
+ log.emit(json!({"kind":"suite_end", "failures":log.failures,
+ "page_only_isolation":"not inferred; inspect witness results",
+ "retirement_evidence":"tui::board::activity; plugin_remote::javascript; apps::io::transcript::runner"}));
+ assert_eq!(
+ log.failures, 0,
+ "raw JSONL retains observed harness/shell/drain failures"
+ );
+}
diff --git a/crates/cli/tests/integration/tui/fault_acceptance/cases.rs b/crates/cli/tests/integration/tui/fault_acceptance/cases.rs
new file mode 100644
index 0000000000..1abb4433b8
--- /dev/null
+++ b/crates/cli/tests/integration/tui/fault_acceptance/cases.rs
@@ -0,0 +1,289 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use fixture::{DEDICATED, FAULT, SHARED, remote};
+
+pub(super) fn run(vm: &str, mode: &str, log: &mut measure::Log) {
+ log.context = json!({"vm":vm,"fault":mode});
+ let directory = tempfile::tempdir().unwrap();
+ let mut host = CandidateFixture::new(directory.path().join("root"));
+ fixture::serve(&mut host);
+ let runtime = Runtime::new().unwrap();
+ let (client, notices) = runtime.block_on(fixture::connect(&host.root));
+ runtime.block_on(async {
+ for (package, title, allocation) in [
+ (FAULT, "Fault lab", vm),
+ (SHARED, "Shared witness", "shared"),
+ (DEDICATED, "Dedicated witness", "dedicated"),
+ ] {
+ fixture::install(&client, directory.path(), package, title, allocation).await;
+ }
+ });
+ let mut tui = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+ let mut meter = measure::Meter::new(Instant::now());
+ let mut probes = Vec::new();
+ let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ tui.wait_for("Fault lab");
+ tui.click_text("Fault lab");
+ tui.wait_for("Last-good acceptance content");
+ tui.wait_for("Retained record 059");
+ tui.click_page_text("Retained record 059");
+ tui.send(b"\x06\x1b[200~Retained record 040\x1b[201~");
+ tui.wait_for("1/1");
+ tui.send(b"\x1b");
+ tui.wait_until(|screen| !screen.contains("1/1") && screen.contains("Retained record 040"));
+ log.rss("before_fault", "host", host.child.as_ref().unwrap().id());
+ log.rss("before_fault", "tui", tui.child.id());
+ let trigger_ns = meter.now();
+ let control = runtime.block_on(remote(
+ &client,
+ FAULT,
+ "control",
+ json!({"mode":mode, "refresh":mode != "runaway_submit"}),
+ ));
+ log.emit(
+ json!({"kind":"trigger", "start_ns":trigger_ns,"end_ns":meter.now(),
+ "control":control}),
+ );
+ if mode == "runaway_submit" {
+ tui.send(&fixture::click(
+ &tui.screen.snapshot().unwrap().screen,
+ "Run presenter",
+ ));
+ }
+ let arm = control.as_ref().expect("fault armed")["arm"]
+ .as_u64()
+ .unwrap();
+ let entered = runtime.block_on(fixture::entered(&host.root, arm));
+ assert_eq!(entered["mode"], mode);
+ assert_eq!(
+ entered["callback"],
+ match mode {
+ "runaway_submit" => "submit",
+ "overflow" => "control",
+ _ => "read",
+ }
+ );
+ log.emit(
+ json!({"kind":"backend_entry_barrier", "observed_ns":meter.now(),
+ "marker":entered, "source":"read-only plugin_data in this fresh Root",
+ "phase":if mode == "overflow" {"after bounded synchronous burst reply"}
+ else if matches!(mode, "invalid" | "duplicate" | "oversized") {
+ "UI invoked its backend before samples; invalid reply may already have settled"
+ } else {"UI invoked its backend before samples; loop-entry time is not observed"}}),
+ );
+ if matches!(mode, "invalid" | "duplicate" | "oversized") {
+ // Exercise retained content after rejection has reached the UI,
+ // rather than racing the still-valid frame ahead of that reply.
+ tui.wait_for("This page could not be loaded or saved.");
+ log.emit(json!({"kind":"page_failure_observed", "at_ns":meter.now(),
+ "frame":tui.screen.snapshot().unwrap().screen}));
+ }
+ probes = spawn_probes(&runtime, &client, mode.starts_with("runaway"), meter.origin);
+ let screen = tui.screen.snapshot().unwrap().screen;
+ if let Some((row, col)) = fixture::position(&screen, "Retained record 040") {
+ let before = visible_records(&screen);
+ assert!(before.len() >= 2 && before[0] >= 3 && before[1] == before[0] + 1);
+ let expected = [before[0] - 3, before[1] - 3];
+ let bytes = format!("\x1b[<64;{};{}M", col + 1, row + 1);
+ // Reader wheel-up moves three source lines. A notice shifting every
+ // physical row cannot satisfy this change in visible source content.
+ let mut sample = meter.input(&mut tui, bytes.as_bytes(), |next| {
+ visible_records(next).starts_with(&expected)
+ });
+ sample["before_records"] = json!(before);
+ sample["after_records"] =
+ json!(visible_records(&tui.screen.snapshot().unwrap().screen));
+ sample["before_frame"] = json!(screen);
+ log.sample("cached_scroll", sample);
+ } else {
+ log.failures += 1;
+ log.emit(json!({"kind":"missing_cached_anchor"}));
+ }
+ log.sample(
+ "palette_open",
+ meter.input(&mut tui, b"\x10", |screen| {
+ screen.contains("Search commands…")
+ }),
+ );
+ log.sample(
+ "shell_input",
+ meter.input(&mut tui, b"faultprobe", |screen| {
+ screen.contains("faultprobe")
+ }),
+ );
+ log.sample(
+ "palette_close",
+ meter.input(&mut tui, b"\x1b", |screen| !screen.contains("faultprobe")),
+ );
+ log.sample(
+ "ctrl_q_confirmation",
+ meter.input(&mut tui, b"\x11", |screen| screen.contains("Force quit")),
+ );
+ // Enter is the existing safe default: cancellation, never force shutdown.
+ log.sample(
+ "ctrl_q_default_cancel",
+ meter.input(&mut tui, b"\r", |screen| !screen.contains("Force quit")),
+ );
+ log.emit(json!({"kind":"post_fault_frame", "at_ns":meter.now(),
+ "last_good_visible":tui.screen.snapshot().unwrap().screen.contains("Last-good acceptance content"),
+ "frame":tui.screen.snapshot().unwrap().screen}));
+ let before_leave = runtime.block_on(remote(&client, FAULT, "stats", Value::Null));
+ log.emit(json!({"kind":"before_leave", "at_ns":meter.now(), "stats":before_leave}));
+ if let Ok(stats) = &before_leave {
+ if mode == "overflow" && stats["invalidated"].as_u64().unwrap_or(0) == 0 {
+ log.failures += 1;
+ }
+ if !matches!(mode, "runaway_submit" | "overflow") && stats["entered"] == 0 {
+ log.failures += 1;
+ }
+ } else {
+ log.failures += 1;
+ }
+ // A native page still loads, and leaving the remote page retires its reads.
+ let leave_ns = meter.now();
+ tui.command("Open settings");
+ tui.wait_for("Appearance");
+ tui.click_page_text("Appearance");
+ tui.wait_for("Maka dark ▾");
+ log.emit(
+ json!({"kind":"native_page_after_fault", "start_ns":leave_ns,
+ "complete_frame_parsed_ns":meter.now(), "frame":tui.screen.snapshot().unwrap().screen}),
+ );
+ }));
+ if let Err(error) = outcome {
+ log.failures += 1;
+ let message = error
+ .downcast_ref::<String>()
+ .map(String::as_str)
+ .or_else(|| error.downcast_ref::<&str>().copied())
+ .unwrap_or("non-string panic");
+ log.emit(json!({"kind":"case_error","error":message}));
+ }
+ for probe in probes {
+ let observation = runtime.block_on(probe).unwrap();
+ if observation["observations"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .any(|sample| sample["result"].get("Err").is_some())
+ {
+ log.failures += 1;
+ }
+ log.emit(observation);
+ }
+ // Explicit normal UI close; fallback process kills cannot produce a pass.
+ tui.filter_command("Close interface only");
+ tui.click_text("Close interface only");
+ tui.finish();
+ let settled = runtime.block_on(async {
+ let deadline = Instant::now() + Duration::from_secs(8);
+ loop {
+ let result = remote(&client, FAULT, "stats", Value::Null).await;
+ if result.as_ref().map_or(true, |value| value["active"] == 0)
+ || Instant::now() >= deadline
+ {
+ break result;
+ }
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ });
+ if let Ok(stats) = &settled {
+ if stats["active"] != 0 || stats["closed"] != stats["opened"] {
+ log.failures += 1;
+ }
+ } else {
+ // UI failures must leave their business activation available. Closing
+ // the page and its sources is the proof; UI code cannot write counters.
+ log.failures += 1;
+ }
+ log.emit(
+ json!({"kind":"closed_resources", "at_ns":meter.now(),"stats":settled,
+ "stats_required":true,
+ "business_scope":"all UI fault modes must preserve the original business activation"}),
+ );
+ for package in [SHARED, DEDICATED] {
+ let result = runtime.block_on(remote(&client, package, "ping", Value::Null));
+ if result.is_err() {
+ log.failures += 1;
+ }
+ log.emit(json!({"kind":"witness_after_retirement","package":package,"result":result}));
+ }
+ log.emit(json!({"kind":"host_after_close","result":runtime.block_on(
+ client.request(Operation::HostDiagnosticsQuery, json!({}))).map_err(|error| error.to_string())}));
+ log.rss("after_close", "host", host.child.as_ref().unwrap().id());
+ client.disconnect();
+ runtime.block_on(async {
+ client.closed().await;
+ notices.await.unwrap();
+ });
+ let start = meter.now();
+ host.retire_registered();
+ let status = host.wait_for_exit();
+ let released = maka_event_log::root::RootOwner::open(
+ &host.root,
+ &maka_event_log::root::RootNamespaces::for_current_account().unwrap(),
+ )
+ .is_ok();
+ log.emit(
+ json!({"kind":"host_drain", "start_ns":start,"end_ns":meter.now(),
+ "duration_ns":meter.now() - start,"success":status.success(),
+ "registration_removed":!host.registration.exists(),"root_released":released}),
+ );
+ if !status.success() || host.registration.exists() || !released {
+ log.failures += 1;
+ }
+}
+
+fn visible_records(screen: &str) -> Vec<u16> {
+ screen
+ .lines()
+ .filter_map(|line| {
+ let (_, record) = line.split_once("Retained record ")?;
+ record.get(..3)?.parse().ok()
+ })
+ .collect()
+}
+
+fn spawn_probes(
+ runtime: &Runtime,
+ client: &maka_client::Client,
+ repeated: bool,
+ origin: Instant,
+) -> Vec<tokio::task::JoinHandle<Value>> {
+ ["native_host", SHARED, DEDICATED].into_iter().map(|package| {
+ let client = client.clone();
+ runtime.spawn(async move {
+ let deadline = Instant::now() + Duration::from_secs(6);
+ let mut rows = Vec::new();
+ loop {
+ let start = origin.elapsed().as_nanos() as u64;
+ let result = if package == "native_host" {
+ client.request(Operation::HostStatus, json!({})).await.map_err(|error| error.to_string())
+ } else { remote(&client, package, "ping", Value::Null).await };
+ let failed = result.is_err();
+ rows.push(json!({"start_ns":start,"end_ns":origin.elapsed().as_nanos() as u64,"result":result}));
+ if !repeated || failed || Instant::now() >= deadline { break; }
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ }
+ json!({"kind":"witness_during_fault","package":package,"observations":rows})
+ })
+ }).collect()
+}
diff --git a/crates/cli/tests/integration/tui/fault_acceptance/fixture.rs b/crates/cli/tests/integration/tui/fault_acceptance/fixture.rs
new file mode 100644
index 0000000000..062120a5f2
--- /dev/null
+++ b/crates/cli/tests/integration/tui/fault_acceptance/fixture.rs
@@ -0,0 +1,209 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+
+pub(crate) const FAULT: &str = "example.acceptance-fault";
+pub(crate) const SHARED: &str = "example.acceptance-shared";
+pub(crate) const DEDICATED: &str = "example.acceptance-dedicated";
+
+pub(crate) fn serve(host: &mut CandidateFixture) {
+ host.child = Some(
+ Command::new(env!("CARGO_BIN_EXE_maka"))
+ .args(["host", "serve", "--root"])
+ .arg(&host.root)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::inherit())
+ .spawn()
+ .unwrap(),
+ );
+ host.wait_for_registration();
+}
+
+pub(crate) async fn connect(
+ root: &std::path::Path,
+) -> (maka_client::Client, tokio::task::JoinHandle<()>) {
+ let discovery = maka_client::local::read_discovery(root).unwrap();
+ let (client, mut notices) = maka_client::Client::connect(
+ maka_client::local::open_stream(&discovery.endpoint)
+ .await
+ .unwrap(),
+ &discovery.root_id,
+ &discovery.host_epoch,
+ maka_client::Operations,
+ )
+ .await
+ .unwrap();
+ let task = tokio::spawn(async move { while notices.recv().await.is_some() {} });
+ (client, task)
+}
+
+pub(crate) async fn install(
+ client: &maka_client::Client,
+ directory: &std::path::Path,
+ package_id: &str,
+ title: &str,
+ vm: &str,
+) {
+ let package = directory.join(package_id);
+ std::fs::create_dir(&package).unwrap();
+ std::fs::write(
+ package.join("host.mjs"),
+ include_str!("../../../fixtures/faults-plugin/host.mjs")
+ .replace("__ACCEPTANCE_TITLE__", title),
+ )
+ .unwrap();
+ let mut runtime = json!({"entry":"host.mjs", "sdkVersion":2});
+ // Omission deliberately exercises the Host's production Shared default.
+ if vm == "dedicated" {
+ runtime["vm"] = json!(vm);
+ }
+ std::fs::write(
+ package.join("faults-ui.mjs"),
+ include_str!("../../../fixtures/faults-plugin/faults-ui.mjs"),
+ )
+ .unwrap();
+ std::fs::write(
+ package.join("maka.extension.json"),
+ json!({"schemaVersion":1,"id":package_id,"runtime":runtime}).to_string(),
+ )
+ .unwrap();
+ client
+ .request(
+ Operation::PluginPackageInstall,
+ json!({"sourcePath":package}),
+ )
+ .await
+ .unwrap();
+ client
+ .request(
+ Operation::PluginCompositionApply,
+ json!({"operations":[{
+ "type":"insert","rootId":"profile","entry":{"id":package_id,"packageId":package_id}
+ }]}),
+ )
+ .await
+ .unwrap();
+ tokio::time::timeout(Duration::from_secs(10), async {
+ loop {
+ if remote(client, package_id, "ping", Value::Null)
+ .await
+ .is_ok()
+ {
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ })
+ .await
+ .expect("acceptance package activation");
+}
+
+/// Close the exact document even if the callback fails or exceeds its deadline.
+pub(crate) async fn remote(
+ client: &maka_client::Client,
+ package: &str,
+ method: &str,
+ input: Value,
+) -> Result<Value, String> {
+ let binding = RemoteBinding::Package {
+ package_id: package.into(),
+ method: method.into(),
+ session_id: None,
+ };
+ let RemoteResult::Bound { target, .. } = client
+ .plugin_remote(RemoteRequest::Bind {
+ binding: binding.clone(),
+ })
+ .await
+ .map_err(|error| error.to_string())?
+ else {
+ return Err("not bound".into());
+ };
+ let RemoteResult::Document { document } = client
+ .plugin_remote(RemoteRequest::OpenDocument)
+ .await
+ .map_err(|error| error.to_string())?
+ else {
+ return Err("no document".into());
+ };
+ let result = tokio::time::timeout(
+ Duration::from_secs(8),
+ client.plugin_remote(RemoteRequest::Call {
+ binding,
+ target,
+ document,
+ input,
+ }),
+ )
+ .await;
+ let close = client
+ .plugin_remote(RemoteRequest::CloseDocument { document })
+ .await;
+ close.map_err(|error| format!("document close: {error}"))?;
+ match result {
+ Ok(Ok(RemoteResult::Value { value })) => Ok(value),
+ other => Err(format!("{other:?}")),
+ }
+}
+
+pub(crate) fn position(screen: &str, text: &str) -> Option<(usize, usize)> {
+ screen
+ .lines()
+ .enumerate()
+ .find_map(|(row, line)| line.find(text).map(|byte| (row, line[..byte].width())))
+}
+
+pub(crate) fn click(screen: &str, text: &str) -> Vec<u8> {
+ let (row, col) = position(screen, text).unwrap_or_else(|| panic!("missing {text}: {screen}"));
+ format!(
+ "\x1b[<0;{};{}M\x1b[<0;{};{}m",
+ col + 1,
+ row + 1,
+ col + 1,
+ row + 1
+ )
+ .into_bytes()
+}
+
+/// Observe the callback's committed entry marker outside its potentially busy VM.
+/// This is read-only inspection of this fixture's fresh synthetic Root only.
+pub(crate) async fn entered(root: &std::path::Path, arm: u64) -> Value {
+ use sqlx::Connection;
+ let mut db = sqlx::SqliteConnection::connect_with(
+ &sqlx::sqlite::SqliteConnectOptions::new()
+ .filename(root.join(maka_event_log::root::ROOT_DATABASE))
+ .read_only(true),
+ )
+ .await
+ .unwrap();
+ let result = tokio::time::timeout(Duration::from_secs(8), async {
+ loop {
+ let raw: Option<String> = sqlx::query_scalar(
+ "SELECT value_json FROM plugin_data WHERE package_id=? AND key=? AND value_json IS NOT NULL",
+ ).bind(FAULT).bind(format!("acceptance/entered/{arm}"))
+ .fetch_optional(&mut db).await.unwrap();
+ if let Some(raw) = raw { break serde_json::from_str::<Value>(&raw).unwrap(); }
+ tokio::time::sleep(Duration::from_millis(1)).await;
+ }
+ }).await;
+ db.close().await.unwrap();
+ result.expect("the actual fault callback must enter before local-input samples")
+}
diff --git a/crates/cli/tests/integration/tui/fault_acceptance/measure.rs b/crates/cli/tests/integration/tui/fault_acceptance/measure.rs
new file mode 100644
index 0000000000..a05f914cb8
--- /dev/null
+++ b/crates/cli/tests/integration/tui/fault_acceptance/measure.rs
@@ -0,0 +1,198 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use sha2::{Digest, Sha256};
+
+pub(crate) struct Log {
+ file: Option<File>,
+ pub smoke: bool,
+ pub failures: usize,
+ pub context: Value,
+}
+
+impl Log {
+ pub fn new(suite: &str) -> Self {
+ let smoke = match std::env::var("MAKA_TUI_ACCEPTANCE_MODE").as_deref() {
+ Ok("smoke") => true,
+ Err(std::env::VarError::NotPresent) | Ok("formal") => false,
+ other => panic!("MAKA_TUI_ACCEPTANCE_MODE must be smoke or formal: {other:?}"),
+ };
+ assert!(
+ smoke || !cfg!(debug_assertions),
+ "formal acceptance requires release"
+ );
+ let file = std::env::var_os("MAKA_TUI_ACCEPTANCE_OUTPUT").map(|path| {
+ std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(path)
+ .unwrap()
+ });
+ let mut log = Self {
+ file,
+ smoke,
+ failures: 0,
+ context: json!({}),
+ };
+ let binary = std::fs::read(env!("CARGO_BIN_EXE_maka")).unwrap();
+ let cpu = std::fs::read_to_string("/proc/cpuinfo")
+ .ok()
+ .and_then(|value| {
+ value
+ .lines()
+ .find(|line| line.starts_with("model name"))
+ .map(str::to_owned)
+ });
+ log.emit(json!({"kind":"metadata", "suite":suite, "schema":1,
+ "mode":if smoke {"smoke"} else {"formal"},
+ "profile":if cfg!(debug_assertions) {"debug"} else {"release"},
+ "binary_sha256":format!("{:x}", Sha256::digest(binary)),
+ "cpu":cpu, "os":std::env::consts::OS, "arch":std::env::consts::ARCH,
+ "viewport":{"columns":120,"rows":40},
+ "clock":"std::time::Instant; ns relative to each case",
+ "metric":"input_to_complete_pty_frame_parsed_upper_bound",
+ "window_present_measured":false,
+ "cache_condition":"OS page cache uncontrolled; no drop_caches"}));
+ log
+ }
+
+ pub fn emit(&mut self, mut row: Value) {
+ row["case"] = self.context.clone();
+ let line = serde_json::to_string(&row).unwrap();
+ println!("{line}");
+ if let Some(file) = &mut self.file {
+ writeln!(file, "{line}").unwrap();
+ file.flush().unwrap();
+ }
+ }
+
+ pub fn sample(&mut self, operation: &str, mut row: Value) {
+ self.failures += usize::from(!row["error"].is_null());
+ row["kind"] = json!("sample");
+ row["operation"] = json!(operation);
+ self.emit(row);
+ }
+
+ pub fn rss(&mut self, stage: &str, process: &str, pid: u32) {
+ let result = std::fs::read_to_string(format!("/proc/{pid}/status"));
+ let value = match result {
+ Ok(text) => {
+ let read = |name: &str| {
+ text.lines()
+ .find(|line| line.starts_with(name))
+ .and_then(|line| line.split_whitespace().nth(1))
+ .and_then(|value| value.parse::<u64>().ok())
+ };
+ json!({"rss_kib":read("VmRSS:"),"hwm_kib":read("VmHWM:")})
+ }
+ Err(error) => json!({"error":error.to_string()}),
+ };
+ self.emit(
+ json!({"kind":"memory", "stage":stage, "process":process, "pid":pid,
+ "value":value,"scope":"point RSS and process-lifetime HWM"}),
+ );
+ }
+}
+
+/// Reuse the production PTY fixture's parser and synchronized-frame tracking.
+/// The timestamp follows parsing, so it bounds receipt rather than window paint.
+pub(crate) struct Meter {
+ pub origin: Instant,
+ tail: [u8; 8],
+ drawing: bool,
+ pub sequence: u64,
+ completed_ns: u64,
+ chunks: Vec<Value>,
+}
+
+impl Meter {
+ pub fn new(origin: Instant) -> Self {
+ Self {
+ origin,
+ tail: [0; 8],
+ drawing: false,
+ sequence: 0,
+ completed_ns: 0,
+ chunks: Vec::new(),
+ }
+ }
+
+ pub fn now(&self) -> u64 {
+ self.origin.elapsed().as_nanos() as u64
+ }
+
+ pub fn read(&mut self, tui: &mut Pty) {
+ tui.output.clear();
+ tui.read();
+ let parsed_ns = self.now();
+ for &byte in &tui.output {
+ self.tail.copy_within(1.., 0);
+ self.tail[7] = byte;
+ if &self.tail == b"\x1b[?2026h" {
+ self.drawing = true;
+ } else if &self.tail == b"\x1b[?2026l" && self.drawing {
+ self.drawing = false;
+ self.sequence += 1;
+ self.completed_ns = parsed_ns;
+ }
+ }
+ if !tui.output.is_empty() {
+ self.chunks
+ .push(json!({"bytes":tui.output.len(),"parsed_ns":parsed_ns,
+ "frame_seq":self.sequence}));
+ }
+ }
+
+ pub fn input(&mut self, tui: &mut Pty, bytes: &[u8], ready: impl Fn(&str) -> bool) -> Value {
+ self.chunks.clear();
+ let before = self.sequence;
+ let start = self.now();
+ tui.send(bytes);
+ self.wait(tui, start, before, ready)
+ }
+
+ pub fn wait(
+ &mut self,
+ tui: &mut Pty,
+ start: u64,
+ before: u64,
+ ready: impl Fn(&str) -> bool,
+ ) -> Value {
+ let deadline = Instant::now() + Duration::from_secs(10);
+ let error = loop {
+ self.read(tui);
+ let screen = tui.screen.snapshot().unwrap().screen;
+ if self.sequence > before && tui.frames.ready() && ready(&screen) {
+ break None;
+ }
+ if tui.child.try_wait().unwrap().is_some() {
+ break Some("TUI exited".to_owned());
+ }
+ if Instant::now() >= deadline {
+ break Some("complete frame timeout".to_owned());
+ }
+ };
+ json!({"start_ns":start, "complete_frame_parsed_ns":self.completed_ns,
+ "observed_end_ns":self.now(), "frame_seq_before":before,"frame_seq_after":self.sequence,
+ "duration_ns":error.is_none().then(|| self.completed_ns.saturating_sub(start)),
+ "error":error, "frame":tui.screen.snapshot().unwrap().screen,
+ "chunks":std::mem::take(&mut self.chunks)})
+ }
+}
diff --git a/crates/cli/tests/integration/tui/fault_acceptance/mutation.rs b/crates/cli/tests/integration/tui/fault_acceptance/mutation.rs
new file mode 100644
index 0000000000..897446083b
--- /dev/null
+++ b/crates/cli/tests/integration/tui/fault_acceptance/mutation.rs
@@ -0,0 +1,233 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{CandidateFixture, Operation, Pty, Runtime, Value, fixture, json};
+use std::{path::Path, time::Duration};
+
+mod applied;
+
+const PACKAGE: &str = "example.mutation-fault";
+
+#[test]
+fn committed_javascript_submit_survives_vm_death_and_reopens_with_original_recovery() {
+ let directory = tempfile::tempdir().unwrap();
+ let mut host = CandidateFixture::new(directory.path().join("root"));
+ fixture::serve(&mut host);
+ let runtime = Runtime::new().unwrap();
+ let operation = uuid::Uuid::new_v4().to_string();
+ let (client, notices) = runtime.block_on(fixture::connect(&host.root));
+ runtime.block_on(install(&client, directory.path(), &operation));
+ let mut tui = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+ tui.wait_for("Mutation fault");
+ tui.click_text("Mutation fault");
+ tui.wait_for("Original note");
+ tui.click_text("Original note");
+ tui.send(b"\x1b[200~Exact original payload\x1b[201~");
+ tui.click_text("Commit then fail");
+ let committed = runtime.block_on(async {
+ tokio::time::timeout(Duration::from_secs(8), async {
+ loop {
+ if let Some(value) = record(&host.root, &format!("operations/{operation}")).await {
+ break value;
+ }
+ tokio::time::sleep(Duration::from_millis(1)).await;
+ }
+ })
+ .await
+ .expect("real JS mutation and receipt must commit before VM death")
+ });
+ assert_eq!(committed["operation"], operation);
+ assert_eq!(
+ committed["submission"]["fields"]["note"],
+ "Exact original payload"
+ );
+ assert_eq!(committed["submissions"], 1);
+ assert_eq!(
+ runtime.block_on(record(&host.root, "business")).unwrap()["count"],
+ 1
+ );
+ tui.wait_for("result is unconfirmed");
+ tui.wait_for("Check original submission");
+ // A normal close flushes the post-failure state. Crashing immediately after
+ // dispatch could accidentally pass using an obsolete pre-dispatch checkpoint.
+ tui.close_terminal();
+ tui.finish();
+ let saved = checkpoint(directory.path(), &host.root_id);
+ let original = saved["apps"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .find(|entry| entry["pending"]["input"]["revision"] == operation)
+ .expect("the completed failure must preserve its original frozen proof");
+ assert_eq!(
+ original["pending"]["recovery"],
+ json!({"operation":operation})
+ );
+ assert_eq!(original["pending"]["input"]["action"], "runaway");
+ assert_eq!(
+ original["pending"]["input"]["fields"],
+ committed["submission"]["fields"]
+ );
+ assert_eq!(original["pending"]["withheld"], false);
+ assert!(!original["entry"]["target"]["registration"].is_null());
+ client.disconnect();
+ runtime.block_on(async {
+ client.closed().await;
+ notices.await.unwrap();
+ });
+ host.retire_registered();
+ assert!(host.wait_for_exit().success());
+
+ // Restart the same synthetic Root to obtain a healthy VM and a new target.
+ // The TUI must still recover the original operation, never re-submit it.
+ fixture::serve(&mut host);
+ let (client, notices) = runtime.block_on(fixture::connect(&host.root));
+ let mut reopened = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+ reopened.wait_for("Check original submission");
+ reopened.wait_until(|screen| {
+ screen.contains("Exact original payload")
+ && !screen.contains("connecting")
+ && !screen.contains("not connected")
+ && !screen.contains("connection failed")
+ });
+ let before = runtime.block_on(stats(&client));
+ assert_eq!(before["business"]["count"], 1);
+ assert_eq!(
+ before["original"]["submissions"], 1,
+ "restart never resubmits"
+ );
+ assert_eq!(before["recoveries"], json!([]));
+ reopened.click_text("Check original submission");
+ reopened.wait_for("Recovered original: Exact original payload");
+ let settled = runtime.block_on(stats(&client));
+ assert_eq!(settled["recoveries"], json!([operation]));
+ assert_eq!(settled["business"]["count"], 1);
+ assert_eq!(
+ settled["original"], committed,
+ "recovery only reads the original receipt"
+ );
+ reopened.close_terminal();
+ reopened.finish();
+ assert!(
+ checkpoint(directory.path(), &host.root_id)["apps"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .all(|entry| entry["pending"]["input"]["revision"] != operation)
+ );
+ client.disconnect();
+ runtime.block_on(async {
+ client.closed().await;
+ notices.await.unwrap();
+ });
+ host.retire_registered();
+ assert!(host.wait_for_exit().success());
+}
+
+async fn install(client: &maka_client::Client, directory: &Path, operation: &str) {
+ let package = directory.join("plugin");
+ std::fs::create_dir(&package).unwrap();
+ std::fs::write(
+ package.join("host.mjs"),
+ include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/../runtime-host/tests/fixtures/terminal-mutation-fault.mjs"
+ ))
+ .replace("__MUTATION_OPERATION__", operation),
+ )
+ .unwrap();
+ std::fs::write(
+ package.join("mutation-ui.mjs"),
+ include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/../runtime-host/tests/fixtures/mutation-ui.mjs"
+ )),
+ )
+ .unwrap();
+ std::fs::write(
+ package.join("maka.extension.json"),
+ json!({
+ "schemaVersion":1,"id":PACKAGE,
+ "runtime":{"entry":"host.mjs","sdkVersion":2,"vm":"dedicated"}
+ })
+ .to_string(),
+ )
+ .unwrap();
+ client
+ .request(
+ Operation::PluginPackageInstall,
+ json!({"sourcePath":package}),
+ )
+ .await
+ .unwrap();
+ client
+ .request(
+ Operation::PluginCompositionApply,
+ json!({"operations":[{
+ "type":"insert","rootId":"profile","entry":{"id":PACKAGE,"packageId":PACKAGE}
+ }]}),
+ )
+ .await
+ .unwrap();
+ tokio::time::timeout(Duration::from_secs(10), async {
+ while fixture::remote(client, PACKAGE, "stats", Value::Null)
+ .await
+ .is_err()
+ {
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ })
+ .await
+ .unwrap();
+}
+
+async fn stats(client: &maka_client::Client) -> Value {
+ fixture::remote(client, PACKAGE, "stats", Value::Null)
+ .await
+ .unwrap()
+}
+
+async fn record(root: &Path, key: &str) -> Option<Value> {
+ use sqlx::Connection;
+ let mut db = sqlx::SqliteConnection::connect_with(
+ &sqlx::sqlite::SqliteConnectOptions::new()
+ .filename(root.join(maka_event_log::root::ROOT_DATABASE))
+ .read_only(true),
+ )
+ .await
+ .unwrap();
+ let raw: Option<String> = sqlx::query_scalar(
+ "SELECT value_json FROM plugin_data WHERE package_id=? AND key=? AND value_json IS NOT NULL",
+ ).bind(PACKAGE).bind(key).fetch_optional(&mut db).await.unwrap();
+ db.close().await.unwrap();
+ raw.map(|raw| serde_json::from_str(&raw).unwrap())
+}
+
+fn checkpoint(directory: &Path, root: &str) -> Value {
+ serde_json::from_slice(
+ &std::fs::read(
+ directory
+ .join("tui-state")
+ .join(root)
+ .join("default/state.json"),
+ )
+ .unwrap(),
+ )
+ .unwrap()
+}
diff --git a/crates/cli/tests/integration/tui/fault_acceptance/mutation/applied.rs b/crates/cli/tests/integration/tui/fault_acceptance/mutation/applied.rs
new file mode 100644
index 0000000000..3252d6f7f8
--- /dev/null
+++ b/crates/cli/tests/integration/tui/fault_acceptance/mutation/applied.rs
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use maka_protocol::plugin::{RemoteBinding, RemoteRequest, RemoteResult};
+use std::collections::BTreeSet;
+
+#[test]
+fn applied_same_route_after_ui_failure_reads_a_new_document_without_replaying_submit() {
+ for (action, label) in [
+ ("ui-same-route-throw", "Save then UI throw"),
+ ("ui-same-route-runaway", "Save then UI loop"),
+ ] {
+ let directory = tempfile::tempdir().unwrap();
+ let mut host = CandidateFixture::new(directory.path().join("root"));
+ fixture::serve(&mut host);
+ let runtime = Runtime::new().unwrap();
+ let operation = uuid::Uuid::new_v4().to_string();
+ let (client, notices) = runtime.block_on(fixture::connect(&host.root));
+ runtime.block_on(install(&client, directory.path(), &operation));
+ let original_target = runtime.block_on(target(&client));
+ let mut tui = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+ tui.wait_for("Mutation fault");
+ tui.click_text("Mutation fault");
+ tui.wait_for("Original note");
+ tui.click_text("Original note");
+ tui.send(b"\x1b[200~Known receipt payload\x1b[201~");
+ tui.click_text(label);
+ // This requires a real new Page Read at the original null route: the
+ // failed UI VM cannot render the durable backend receipt by itself.
+ tui.wait_for("Saved same route: Known receipt payload");
+ let screen = tui.screen.snapshot().unwrap().screen;
+ assert!(!screen.contains("result is unconfirmed"), "{screen}");
+ assert!(!screen.contains("Check original submission"), "{screen}");
+ let settled = runtime.block_on(stats(&client));
+ assert_eq!(settled["business"]["count"], 1);
+ assert_eq!(
+ settled["original"]["submissions"], 1,
+ "Submit must never replay"
+ );
+ assert_eq!(settled["original"]["submission"]["action"], action);
+ assert_eq!(
+ settled["original"]["submission"]["fields"]["note"],
+ "Known receipt payload"
+ );
+ assert_eq!(
+ settled["original"]["receipt"],
+ json!({"kind":"applied", "route":null})
+ );
+ assert_eq!(
+ settled["recoveries"],
+ json!([]),
+ "known receipt needs no recovery"
+ );
+ let reads = settled["reads"].as_array().unwrap();
+ assert!(reads.iter().all(|read| read["route"].is_null()));
+ let documents: BTreeSet<_> = reads
+ .iter()
+ .map(|read| read["document"].as_str().unwrap())
+ .collect();
+ assert_eq!(
+ documents.len(),
+ 2,
+ "initial form and fresh result Read use distinct documents: {settled}"
+ );
+ assert_eq!(
+ runtime.block_on(target(&client)),
+ original_target,
+ "UI failure must not retire or reactivate the business registration"
+ );
+ tui.close_terminal();
+ tui.finish();
+ assert!(
+ checkpoint(directory.path(), &host.root_id)["apps"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .all(|entry| entry["pending"]["input"]["revision"] != operation)
+ );
+ client.disconnect();
+ runtime.block_on(async {
+ client.closed().await;
+ notices.await.unwrap();
+ });
+ host.retire_registered();
+ assert!(host.wait_for_exit().success());
+ }
+}
+
+async fn target(client: &maka_client::Client) -> maka_plugins::remote::Target {
+ let result = client
+ .plugin_remote(RemoteRequest::Bind {
+ binding: RemoteBinding::Package {
+ package_id: PACKAGE.into(),
+ method: "terminal".into(),
+ session_id: None,
+ },
+ })
+ .await
+ .unwrap();
+ let RemoteResult::Bound { target, .. } = result else {
+ panic!("terminal was not bound: {result:?}");
+ };
+ target
+}
diff --git a/crates/cli/tests/integration/tui/memory_acceptance.rs b/crates/cli/tests/integration/tui/memory_acceptance.rs
new file mode 100644
index 0000000000..bb5735dc55
--- /dev/null
+++ b/crates/cli/tests/integration/tui/memory_acceptance.rs
@@ -0,0 +1,260 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::super::candidate::CandidateFixture;
+use super::board::performance::proxy;
+use super::*;
+use maka_client::Client;
+use maka_protocol::{
+ Operation,
+ plugin::{RemoteBinding, RemoteRequest, RemoteResult},
+};
+use serde_json::{Value, json};
+use tokio::runtime::Runtime;
+
+mod capacity;
+mod case;
+mod report;
+mod sampler;
+
+const PACKAGE: &str = "example.acceptance-memory";
+const TITLE: &str = "Memory lab";
+
+/// VmRSS is descriptive: the design specifies no process RSS pass threshold.
+#[test]
+#[ignore = "serial fixed-release Host+PTY RSS measurement; writes raw JSONL"]
+fn ten_thousand_lines_page_stream_close_and_page_vm_rss() {
+ assert!(
+ std::path::Path::new("/proc/self/status").is_file(),
+ "requires Linux /proc"
+ );
+ let mut log = report::Report::new();
+ for size in [(120, 40), (55, 24)] {
+ log.context = json!({"columns":size.0,"rows":size.1});
+ let outcome =
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(size, &mut log)));
+ log.caught("case", outcome);
+ }
+ log.finish();
+ assert_eq!(log.failures, 0, "all raw failures are retained in JSONL");
+}
+
+fn run(size: (u16, u16), log: &mut report::Report) {
+ let directory = tempfile::tempdir().unwrap();
+ let mut host = CandidateFixture::new(directory.path().join("root"));
+ host.child = Some(
+ Command::new(env!("CARGO_BIN_EXE_maka"))
+ .args(["host", "serve", "--root"])
+ .arg(&host.root)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::inherit())
+ .spawn()
+ .unwrap(),
+ );
+ let origin = Instant::now();
+ let mut sampler = sampler::Sampler::new(host.child.as_ref().unwrap().id(), origin);
+ let runtime = Runtime::new().unwrap();
+ let (mut client, mut notices, mut relay, mut terminal) = (None, None, None, None);
+ let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ host.wait_for_registration();
+ let (connected, pump) = runtime.block_on(proxy::connect(&host.root));
+ client = Some(connected);
+ notices = Some(pump);
+ let client = client.as_ref().unwrap();
+ relay = Some(runtime.block_on(proxy::DelayProxy::start(
+ &host,
+ directory.path(),
+ Duration::ZERO,
+ origin,
+ )));
+ terminal = Some(Pty::spawn(&["--root", host.root.to_str().unwrap()]));
+ let tui = terminal.as_mut().unwrap();
+ sampler.set_tui(tui.child.id());
+ tui.wait_for("Settings");
+ tui.click_text("Settings");
+ tui.wait_for("Maka dark ▾");
+ tui.resize(size.0, size.1);
+ tui.wait_for("Maka dark ▾");
+ sampler.mark("native_settings_before_install", 0);
+ runtime.block_on(install(client, directory.path()));
+ // The palette freezes its command catalog when opened. This ordinary
+ // directory remains live while the TUI receives the Host's new entry.
+ tui.command("Plugin pages");
+ tui.wait_for(PACKAGE);
+ tui.wait_for(TITLE);
+ tui.command("Open settings");
+ tui.wait_for("Maka dark ▾");
+ sampler.mark("native_settings_after_install", 0);
+ log.emit(
+ json!({"kind":"baseline","at_ns":origin.elapsed().as_nanos() as u64,
+ "page":"native Settings","equivalent_transcript_source":false,
+ "stats":runtime.block_on(case::stats(client))}),
+ );
+ capacity::measure(&runtime, &host.root, &mut sampler, log, "capacity_before");
+ case::exercise(
+ tui,
+ &runtime,
+ client,
+ relay.as_ref().unwrap(),
+ &mut sampler,
+ log,
+ origin,
+ );
+ capacity::measure(&runtime, &host.root, &mut sampler, log, "capacity_after");
+ sampler.mark("final_native_settings", 0);
+ }));
+ log.caught("scenario", outcome);
+ sampler.mark("before_interface_close", 0);
+ log.memory(sampler.stop()); // Join the 10 ms observer before any process exits.
+ log.emit(json!({"kind":"rss_sampler_joined","joined":true,"target_period_ms":10}));
+ if let Some(tui) = terminal.as_mut() {
+ let close = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ // A failed setup can leave the palette open. Close that known
+ // modal and observe dismissal before starting a new palette.
+ if tui.screen.snapshot().unwrap().screen.contains("Commands") {
+ tui.send(b"\x1b");
+ tui.wait_until(|screen| {
+ !screen.contains("Commands") && !screen.contains("No matching commands.")
+ });
+ }
+ tui.filter_command("Close interface only");
+ tui.click_text("Close interface only");
+ tui.finish();
+ }));
+ let normal = close.is_ok();
+ log.caught("interface_close", close);
+ log.emit(json!({"kind":"interface_closed","normal":normal}));
+ }
+ if let Some(client) = &client {
+ let resources = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ let stats = runtime.block_on(case::closed(client));
+ log.emit(json!({"kind":"resources_closed","stats":stats}));
+ }));
+ log.caught("resource_close", resources);
+ }
+ if let Some(proxy) = relay {
+ let fence = proxy.fence();
+ if fence["live_documents"] != 0 {
+ log.failures += 1;
+ }
+ log.emit(json!({"kind":"wire_fence_before_stop","fence":fence,"records_seen":proxy.records().len()}));
+ log.wire(runtime.block_on(proxy.stop()));
+ }
+ if let Some(client) = client {
+ client.disconnect();
+ runtime.block_on(client.closed());
+ }
+ if let Some(pump) = notices {
+ runtime.block_on(pump).unwrap();
+ }
+ host.retire_registered();
+ let status = host.wait_for_exit();
+ log.emit(json!({"kind":"host_drain","success":status.success(),"registration_removed":!host.registration.exists()}));
+ assert!(
+ status.success() && !host.registration.exists(),
+ "Host did not drain normally"
+ );
+}
+
+fn follow_tail(
+ tui: &mut Pty,
+ runtime: &Runtime,
+ client: &Client,
+ log: &mut report::Report,
+ origin: Instant,
+ marker: &str,
+) {
+ // Newer already delivered the last source window. Local find + PageDown
+ // resumes following without starting a redundant asynchronous Tail read.
+ case::find(tui, marker);
+ let before = runtime.block_on(case::stats(client));
+ let mut meter = super::fault_acceptance::measure::Meter::new(origin);
+ let frame = meter.input(tui, b"\x1b[6~", |screen| case::visible(screen, marker));
+ let after = runtime.block_on(case::stats(client));
+ log.emit(
+ json!({"kind":"local_tail_follow","marker":marker,"frame":frame,
+ "before":before,"after":after,"metric":"complete frame barrier; not latency acceptance"}),
+ );
+ assert!(
+ frame["error"].is_null(),
+ "local tail following did not paint: {frame}"
+ );
+ assert_eq!(
+ before["pageReads"], after["pageReads"],
+ "following an already loaded tail fetched history"
+ );
+ assert_eq!(before["viewReads"], after["viewReads"]);
+}
+
+async fn install(client: &Client, directory: &std::path::Path) {
+ let package = directory.join("memory-plugin");
+ std::fs::create_dir(&package).unwrap();
+ std::fs::write(
+ package.join("host.mjs"),
+ include_str!("../../fixtures/memory-plugin/host.mjs"),
+ )
+ .unwrap();
+ std::fs::write(
+ package.join("memory-ui.mjs"),
+ include_str!("../../fixtures/memory-plugin/memory-ui.mjs"),
+ )
+ .unwrap();
+ std::fs::write(
+ package.join("maka.extension.json"),
+ json!({"schemaVersion":1,"id":PACKAGE,
+ "runtime":{"entry":"host.mjs","sdkVersion":2}})
+ .to_string(),
+ )
+ .unwrap();
+ client
+ .request(
+ Operation::PluginPackageInstall,
+ json!({"sourcePath":package}),
+ )
+ .await
+ .unwrap();
+ client
+ .request(
+ Operation::PluginCompositionApply,
+ json!({"operations":[{"type":"insert","rootId":"profile",
+ "entry":{"id":PACKAGE,"packageId":PACKAGE}}]}),
+ )
+ .await
+ .unwrap();
+ tokio::time::timeout(Duration::from_secs(5), async {
+ loop {
+ let status = client
+ .request(Operation::PluginPlatformQuery, json!({"view":"status"}))
+ .await
+ .unwrap();
+ if status["convergence"] == "converged" {
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ })
+ .await
+ .expect("memory fixture activation did not converge");
+ let source = case::stats(client).await;
+ assert_eq!(source["sourceBlocks"], 1000);
+ assert_eq!(source["initialLogicalLines"], 10_000);
+ assert!(source["initialSourceBytes"].as_u64().unwrap() < 32 * 1024 * 1024);
+ assert_eq!(source["activations"], 1);
+}
diff --git a/crates/cli/tests/integration/tui/memory_acceptance/capacity.rs b/crates/cli/tests/integration/tui/memory_acceptance/capacity.rs
new file mode 100644
index 0000000000..727253d4f4
--- /dev/null
+++ b/crates/cli/tests/integration/tui/memory_acceptance/capacity.rs
@@ -0,0 +1,229 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use maka_plugins::{remote::Target, terminal_ui::transcript as wire};
+use uuid::Uuid;
+
+pub(super) fn measure(
+ runtime: &Runtime,
+ root: &std::path::Path,
+ sampler: &mut sampler::Sampler,
+ log: &mut report::Report,
+ phase: &'static str,
+) {
+ let (client, pump) = runtime.block_on(proxy::connect(root));
+ let mut documents = Vec::new();
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ let target = runtime.block_on(case::target(&client, "memory"));
+ for count in 1..=4 {
+ sampler.mark(phase, count);
+ let document = runtime.block_on(case::document(&client));
+ documents.push(document);
+ let result = runtime.block_on(read(&client, &target, document)).unwrap();
+ assert!(matches!(result, RemoteResult::Value { ref value } if value["kind"] == "view"));
+ let records = runtime.block_on(resource(&client, document));
+ let stats = runtime.block_on(case::stats(&client));
+ assert_eq!(stats["active"], count);
+ assert_eq!(stats["activations"], 1);
+ sampler.mark(phase, count);
+ log.emit(
+ json!({"kind":"page_capacity","phase":phase,"page_documents":count,
+ "document":document,"loaded_resource_records":records,"stats":stats,
+ "app_reply_payload_bytes":serde_json::to_vec(&result).unwrap().len()}),
+ );
+ }
+ let fifth = runtime.block_on(case::document(&client));
+ documents.push(fifth);
+ let denied = runtime.block_on(read(&client, &target, fifth));
+ log.emit(
+ json!({"kind":"page_capacity_rejection","phase":phase,"page_documents":4,
+ "attempted":5,"result":format!("{denied:?}")}),
+ );
+ assert!(
+ matches!(denied,
+ Err(maka_client::RequestFailure::Rejected(maka_client::ClientError::Rejected(ref error)))
+ if error.code == maka_protocol::OperationErrorCode::OperationUnavailable
+ && error.message.contains("capacity")),
+ "fifth page was not rejected for capacity: {denied:?}"
+ );
+ }));
+ sampler.mark(
+ if phase == "capacity_before" {
+ "capacity_before_closing"
+ } else {
+ "capacity_after_closing"
+ },
+ 4,
+ );
+ let mut cleanup_ok = true;
+ for document in documents {
+ let closed =
+ runtime.block_on(client.plugin_remote(RemoteRequest::CloseDocument { document }));
+ log.emit(json!({"kind":"page_capacity_close","phase":phase,"document":document,"result":format!("{closed:?}")}));
+ cleanup_ok &= matches!(closed, Ok(RemoteResult::Closed));
+ }
+ let cleanup = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ assert!(cleanup_ok, "one or more exact page documents did not close");
+ let stats = runtime.block_on(case::closed(&client));
+ sampler.mark(phase, 0);
+ log.emit(json!({"kind":"page_capacity_released","phase":phase,"stats":stats}));
+ }));
+ // A new exact document must reuse a released page VM permit without a new
+ // backend activation. This is admission evidence, not an RSS threshold.
+ let reuse = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ if result.is_ok() && cleanup.is_ok() {
+ sampler.mark(
+ if phase == "capacity_before" {
+ "capacity_before_reuse"
+ } else {
+ "capacity_after_reuse"
+ },
+ 1,
+ );
+ let target = runtime.block_on(case::target(&client, "memory"));
+ let document = runtime.block_on(case::document(&client));
+ let reopened = runtime.block_on(read(&client, &target, document));
+ let close =
+ runtime.block_on(client.plugin_remote(RemoteRequest::CloseDocument { document }));
+ log.emit(json!({"kind":"page_capacity_reusable","phase":phase,"read":format!("{reopened:?}"),"close":format!("{close:?}")}));
+ assert!(reopened.is_ok() && matches!(close, Ok(RemoteResult::Closed)));
+ assert_eq!(runtime.block_on(case::stats(&client))["activations"], 1);
+ }
+ }));
+ client.disconnect();
+ runtime.block_on(async {
+ client.closed().await;
+ pump.await.unwrap();
+ });
+ if let Err(error) = result {
+ std::panic::resume_unwind(error);
+ }
+ if let Err(error) = cleanup {
+ std::panic::resume_unwind(error);
+ }
+ if let Err(error) = reuse {
+ std::panic::resume_unwind(error);
+ }
+}
+
+async fn read(
+ client: &Client,
+ target: &Target,
+ document: Uuid,
+) -> Result<RemoteResult, maka_client::RequestFailure> {
+ client
+ .plugin_remote(RemoteRequest::Call {
+ binding: case::binding("memory"),
+ target: target.clone(),
+ document,
+ input: json!({"kind":"read","route":null,"locale":"en"}),
+ })
+ .await
+}
+async fn resource(client: &Client, document: Uuid) -> usize {
+ tokio::time::timeout(Duration::from_secs(5), async {
+ let mount = Uuid::new_v4();
+ let target = case::target(client, "memory-lines.stream").await;
+ let result = client
+ .plugin_remote(RemoteRequest::Open {
+ binding: case::binding("memory-lines.stream"),
+ target,
+ document,
+ input: serde_json::to_value(wire::Open {
+ mount,
+ resource: "memory-lines".into(),
+ route: Value::Null,
+ locale: "en".into(),
+ })
+ .unwrap(),
+ })
+ .await
+ .unwrap();
+ let RemoteResult::Opened { stream } = result else {
+ panic!("expected resource stream: {result:?}")
+ };
+ let fence = loop {
+ match client
+ .plugin_remote(RemoteRequest::Next { document, stream })
+ .await
+ .unwrap()
+ {
+ RemoteResult::Pending => continue,
+ RemoteResult::Item { item } => {
+ let event: wire::Event = serde_json::from_value(item).unwrap();
+ event.validate().unwrap();
+ let wire::Event::Ready { fence } = event else {
+ panic!("expected Ready")
+ };
+ break fence;
+ }
+ result => panic!("expected Ready item: {result:?}"),
+ }
+ };
+ let target = case::target(client, "memory-lines.read").await;
+ let mut request = wire::Read {
+ mount,
+ resource: "memory-lines".into(),
+ fence,
+ direction: wire::Direction::Tail,
+ cursor: None,
+ };
+ let mut records = 0;
+ let mut seen = std::collections::HashSet::new();
+ for _ in 0..64 {
+ let result = client
+ .plugin_remote(RemoteRequest::Call {
+ binding: case::binding("memory-lines.read"),
+ target: target.clone(),
+ document,
+ input: serde_json::to_value(&request).unwrap(),
+ })
+ .await
+ .unwrap();
+ let RemoteResult::Value { value } = result else {
+ panic!("expected resource page")
+ };
+ let page: wire::Page = serde_json::from_value(value).unwrap();
+ page.validate().unwrap();
+ assert_eq!(page.fence, fence);
+ for record in page.records {
+ assert!(
+ matches!(record, wire::Record::Block { .. }),
+ "bounded memory fixture unexpectedly fragmented"
+ );
+ records += 1;
+ }
+ match page.continuation {
+ None => {
+ assert_eq!(records, 256);
+ return records;
+ }
+ Some(cursor) => {
+ assert!(seen.insert(cursor.clone()));
+ request.direction = wire::Direction::Continue;
+ request.cursor = Some(cursor);
+ }
+ }
+ }
+ panic!("memory fixture exceeded its continuation bound");
+ })
+ .await
+ .expect("page capacity resource bootstrap did not settle")
+}
diff --git a/crates/cli/tests/integration/tui/memory_acceptance/case.rs b/crates/cli/tests/integration/tui/memory_acceptance/case.rs
new file mode 100644
index 0000000000..832bba103d
--- /dev/null
+++ b/crates/cli/tests/integration/tui/memory_acceptance/case.rs
@@ -0,0 +1,287 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use maka_plugins::remote::Target;
+use uuid::Uuid;
+
+#[allow(clippy::too_many_arguments)]
+pub(super) fn exercise(
+ tui: &mut Pty,
+ runtime: &Runtime,
+ client: &Client,
+ relay: &proxy::DelayProxy,
+ sampler: &mut sampler::Sampler,
+ log: &mut report::Report,
+ origin: Instant,
+) {
+ sampler.mark("initial_load", 0);
+ open(tui, "Memory end 0999");
+ tui.click_text("Memory end 0999");
+ note(runtime, client, relay, sampler, log, origin, "loaded", 0);
+ // Walk every source page in both directions once, independently of the
+ // subsequent repeated adjacent-window stress. Screen markers prove pages.
+ for (index, edge) in [743, 487, 231].into_iter().enumerate() {
+ sampler.mark("initial_older", index);
+ tui.send(b"\x1b[H");
+ wait_visible(tui, &format!("Memory end {edge:04}"));
+ note(
+ runtime,
+ client,
+ relay,
+ sampler,
+ log,
+ origin,
+ "initial_older",
+ index,
+ );
+ }
+ tui.send(b"\x1b[H");
+ wait_visible(tui, "Memory block 0000");
+ for (index, (last, first)) in [(231, 232), (487, 488), (743, 744)].into_iter().enumerate() {
+ find(tui, &format!("Memory end {last:04}"));
+ sampler.mark("initial_newer", index);
+ tui.send(b"\x1b[6~");
+ wait_visible(tui, &format!("Memory block {first:04}"));
+ note(
+ runtime,
+ client,
+ relay,
+ sampler,
+ log,
+ origin,
+ "initial_newer",
+ index,
+ );
+ }
+ follow_tail(tui, runtime, client, log, origin, "Memory end 0999");
+ let cycles = if log.smoke { 2 } else { 100 };
+ let rounds = if log.smoke { 2 } else { 10 };
+ let mut tail = "Memory end 0999".to_owned();
+ for cycle in 0..cycles {
+ sampler.mark("paging_older", cycle);
+ tui.send(b"\x1b[H");
+ wait_visible(tui, "Memory end 0743");
+ note(
+ runtime,
+ client,
+ relay,
+ sampler,
+ log,
+ origin,
+ "paging_older",
+ cycle,
+ );
+ sampler.mark("paging_newer", cycle);
+ tui.send(b"\x1b[6~");
+ wait_visible(tui, "Memory block 0744");
+ follow_tail(tui, runtime, client, log, origin, &tail);
+ note(
+ runtime,
+ client,
+ relay,
+ sampler,
+ log,
+ origin,
+ "paging_newer",
+ cycle,
+ );
+ let before = runtime.block_on(stats(client));
+ sampler.mark("stream", cycle);
+ for index in cycle * 3..cycle * 3 + 3 {
+ let update = runtime.block_on(remote(client, "memory-append", json!(index)));
+ tail = update["marker"].as_str().unwrap().to_owned();
+ wait_visible(tui, &tail);
+ log.emit(json!({"kind":"append","phase":"stream","iteration":cycle,"index":index,
+ "at_ns":origin.elapsed().as_nanos() as u64,"bytes":update["bytes"],"visible_marker":tail}));
+ }
+ let after = runtime.block_on(stats(client));
+ log.emit(json!({"kind":"stream_counts","iteration":cycle,"before":before,"after":after}));
+ assert_eq!(
+ before["viewReads"], after["viewReads"],
+ "append reread the full View"
+ );
+ assert_eq!(
+ before["pageReads"], after["pageReads"],
+ "append fetched historical pages"
+ );
+ assert_eq!(after["appends"], (cycle + 1) * 3);
+ }
+ close(tui, runtime, client);
+ note(
+ runtime,
+ client,
+ relay,
+ sampler,
+ log,
+ origin,
+ "page_closed",
+ 0,
+ );
+ for round in 0..rounds {
+ sampler.mark("reopen_loading", round);
+ open(tui, &tail);
+ note(
+ runtime,
+ client,
+ relay,
+ sampler,
+ log,
+ origin,
+ "reopen_loaded",
+ round,
+ );
+ close(tui, runtime, client);
+ note(
+ runtime,
+ client,
+ relay,
+ sampler,
+ log,
+ origin,
+ "reopen_closed",
+ round,
+ );
+ }
+ let final_stats = runtime.block_on(stats(client));
+ assert_eq!(
+ final_stats["activations"], 1,
+ "opening a presenter reactivated the business plugin"
+ );
+ assert_eq!(final_stats["updates"], cycles * 3);
+}
+
+// Markdown soft breaks may put a marker on two terminal rows. Border glyphs
+// delimit the viewport; they are not characters of the source text.
+pub(super) fn visible(screen: &str, text: &str) -> bool {
+ screen
+ .replace(['│', '┃'], " ")
+ .split_whitespace()
+ .collect::<Vec<_>>()
+ .join(" ")
+ .contains(text)
+}
+fn wait_visible(tui: &mut Pty, text: &str) {
+ tui.wait_until(|screen| visible(screen, text));
+}
+
+fn open(tui: &mut Pty, tail: &str) {
+ tui.command(TITLE);
+ tui.wait_for("10,000 source lines");
+ wait_visible(tui, tail);
+}
+fn close(tui: &mut Pty, runtime: &Runtime, client: &Client) {
+ tui.command("Open settings");
+ tui.wait_for("Maka dark ▾");
+ runtime.block_on(closed(client));
+}
+pub(super) fn find(tui: &mut Pty, query: &str) {
+ tui.send(format!("\x06\x1b[200~{query}\x1b[201~").as_bytes());
+ tui.wait_for("1/1");
+ tui.send(b"\x1b");
+ tui.wait_until(|screen| !screen.contains("1/1") && visible(screen, query));
+}
+#[allow(clippy::too_many_arguments)]
+fn note(
+ runtime: &Runtime,
+ client: &Client,
+ relay: &proxy::DelayProxy,
+ sampler: &sampler::Sampler,
+ log: &mut report::Report,
+ origin: Instant,
+ phase: &'static str,
+ iteration: usize,
+) {
+ sampler.mark(phase, iteration);
+ log.emit(json!({"kind":"phase","phase":phase,"iteration":iteration,"at_ns":origin.elapsed().as_nanos() as u64,
+ "stats":runtime.block_on(stats(client)),"wire_fence":relay.fence(),
+ "logical_source_lines_are_not_visual_rows":true}));
+}
+
+pub(super) async fn stats(client: &Client) -> Value {
+ remote(client, "memory-stats", Value::Null).await
+}
+pub(super) async fn closed(client: &Client) -> Value {
+ tokio::time::timeout(Duration::from_secs(5), async {
+ loop {
+ let value = stats(client).await;
+ if value["active"] == 0 {
+ assert_eq!(
+ value["closed"], value["opened"],
+ "resource close accounting: {value}"
+ );
+ return value;
+ }
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ })
+ .await
+ .expect("memory reader resources did not close")
+}
+pub(super) fn binding(method: &str) -> RemoteBinding {
+ RemoteBinding::Package {
+ package_id: PACKAGE.into(),
+ method: method.into(),
+ session_id: None,
+ }
+}
+pub(super) async fn target(client: &Client, method: &str) -> Target {
+ let result = client
+ .plugin_remote(RemoteRequest::Bind {
+ binding: binding(method),
+ })
+ .await
+ .unwrap();
+ let RemoteResult::Bound { target, .. } = result else {
+ panic!("expected binding: {result:?}");
+ };
+ target
+}
+pub(super) async fn document(client: &Client) -> Uuid {
+ let result = client
+ .plugin_remote(RemoteRequest::OpenDocument)
+ .await
+ .unwrap();
+ let RemoteResult::Document { document } = result else {
+ panic!("expected document: {result:?}");
+ };
+ document
+}
+pub(super) async fn remote(client: &Client, method: &str, input: Value) -> Value {
+ let target = target(client, method).await;
+ let document = document(client).await;
+ let response = client
+ .plugin_remote(RemoteRequest::Call {
+ binding: binding(method),
+ target,
+ document,
+ input,
+ })
+ .await;
+ let close = client
+ .plugin_remote(RemoteRequest::CloseDocument { document })
+ .await
+ .unwrap();
+ assert!(matches!(close, RemoteResult::Closed));
+ let response = response.unwrap();
+ let RemoteResult::Value { value } = response else {
+ panic!("expected value: {response:?}");
+ };
+ value
+}
diff --git a/crates/cli/tests/integration/tui/memory_acceptance/report.rs b/crates/cli/tests/integration/tui/memory_acceptance/report.rs
new file mode 100644
index 0000000000..c1d32c706b
--- /dev/null
+++ b/crates/cli/tests/integration/tui/memory_acceptance/report.rs
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use sha2::{Digest, Sha256};
+use std::collections::BTreeMap;
+
+pub(super) struct Report {
+ file: Option<File>,
+ binary: String,
+ phases: Vec<(u64, String, u64)>,
+ pub context: Value,
+ pub smoke: bool,
+ pub failures: usize,
+}
+impl Report {
+ pub fn new() -> Self {
+ let smoke = match std::env::var("MAKA_TUI_ACCEPTANCE_MODE").as_deref() {
+ Ok("smoke") => true,
+ Ok("formal") | Err(std::env::VarError::NotPresent) => false,
+ value => panic!("invalid MAKA_TUI_ACCEPTANCE_MODE: {value:?}"),
+ };
+ assert!(
+ smoke || !cfg!(debug_assertions),
+ "formal measurement requires release"
+ );
+ let file = std::env::var_os("MAKA_TUI_ACCEPTANCE_OUTPUT").map(|path| {
+ std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(path)
+ .unwrap()
+ });
+ let mut result = Self {
+ file,
+ binary: hash(),
+ phases: Vec::new(),
+ context: json!({}),
+ smoke,
+ failures: 0,
+ };
+ result.emit(json!({"kind":"metadata","suite":"transcript_process_rss","schema":1,
+ "binary_sha256":result.binary,"mode":if smoke {"smoke"} else {"formal"},
+ "profile":if cfg!(debug_assertions) {"debug"} else {"release"},
+ "os":std::env::consts::OS,"arch":std::env::consts::ARCH,
+ "kernel":std::fs::read_to_string("/proc/sys/kernel/osrelease").ok(),
+ "cpu":std::fs::read_to_string("/proc/cpuinfo").ok().and_then(|text| text.lines().find(|line| line.starts_with("model name")).map(str::to_owned)),
+ "initial_source":{"blocks":1000,"logical_lines_per_block":10,"logical_lines":10000},
+ "cycles_per_viewport":if smoke {2} else {100},"appends_per_cycle":3,
+ "reopen_rounds_per_viewport":if smoke {2} else {10},
+ "sampler_period_ms":10,"clock":"Instant ns from each case origin; actual sample timestamps retained",
+ "rss_scope":"Host process includes business source and page VMs; TUI process includes kernel/cache; observer excluded",
+ "wire_scope":"zero-delay proxy observes TUI and capacity probe clients; independent append/stats control client is excluded",
+ "hwm_scope":"process-lifetime peak, not per-phase peak; sampled RSS can miss shorter peaks",
+ "native_baseline":"Settings shell with same binary/viewport; not an equivalent 10k Chat source",
+ "visual_lines_measured":false,"tui_cache_bytes_measured":false,
+ "absolute_rss_threshold":null,"cache_condition":"OS and allocator caches uncontrolled"}));
+ result
+ }
+ pub fn emit(&mut self, mut value: Value) {
+ value["case"] = self.context.clone();
+ let line = serde_json::to_string(&value).unwrap();
+ println!("{line}");
+ if let Some(file) = &mut self.file {
+ writeln!(file, "{line}").unwrap();
+ file.flush().unwrap();
+ }
+ }
+ pub fn caught(&mut self, phase: &str, outcome: std::thread::Result<()>) {
+ if let Err(error) = outcome {
+ self.failures += 1;
+ let error = error
+ .downcast_ref::<String>()
+ .map(String::as_str)
+ .or_else(|| error.downcast_ref::<&str>().copied())
+ .unwrap_or("non-string panic");
+ self.emit(json!({"kind":"failure","phase":phase,"error":error,"fallback_cleanup_possible":true}));
+ }
+ }
+ pub fn memory(&mut self, rows: Vec<Value>) {
+ let mut groups: BTreeMap<(String, String, u64), Vec<u64>> = BTreeMap::new();
+ let mut baseline = BTreeMap::new();
+ for row in rows {
+ if !row["error"].is_null() {
+ self.failures += 1;
+ }
+ if row["sample_kind"] == "checkpoint" {
+ self.phases.push((
+ row["at_ns"].as_u64().unwrap(),
+ row["phase"].as_str().unwrap().into(),
+ row["iteration"].as_u64().unwrap(),
+ ));
+ }
+ if let Some(rss) = row["rss_kib"].as_u64() {
+ let process = row["process"].as_str().unwrap().to_owned();
+ let phase = row["phase"].as_str().unwrap().to_owned();
+ if phase == "native_settings_after_install" {
+ baseline.insert(process.clone(), rss);
+ }
+ groups
+ .entry((process, phase, row["iteration"].as_u64().unwrap()))
+ .or_default()
+ .push(rss);
+ } else if row["error"].is_null() {
+ self.failures += 1;
+ }
+ self.emit(row);
+ }
+ for ((process, phase, iteration), values) in groups {
+ let base = baseline.get(&process).copied();
+ self.emit(json!({"kind":"rss_summary","process":process,"phase":phase,"iteration":iteration,
+ "samples":values.len(),"first_kib":values.first(),"last_kib":values.last(),
+ "sampled_peak_kib":values.iter().max(),"native_settings_baseline_kib":base,
+ "last_minus_baseline_kib":base.map(|base| *values.last().unwrap() as i64-base as i64)}));
+ }
+ }
+ pub fn wire(&mut self, rows: Vec<Value>) {
+ self.phases.sort_by_key(|entry| entry.0);
+ let mut methods = BTreeMap::new();
+ let mut totals: BTreeMap<(String, String, String), (u64, u64)> = BTreeMap::new();
+ for mut row in rows {
+ let at = row["received_ns"].as_u64().unwrap_or(0);
+ if let Some((_, phase, iteration)) =
+ self.phases.iter().rev().find(|entry| entry.0 <= at)
+ {
+ row["phase"] = json!(phase);
+ row["iteration"] = json!(iteration);
+ } else {
+ row["phase"] = json!("host_startup");
+ }
+ let id = (row["connection"].to_string(), row["request_id"].to_string());
+ if row["direction"] == "client_to_host" {
+ methods.insert(
+ id.clone(),
+ (row["remote_kind"].to_string(), row["method"].to_string()),
+ );
+ }
+ if row["kind"] == "proxy_error" || row["kind"] == "wire_received" {
+ self.failures += 1;
+ }
+ if row["kind"] == "wire" {
+ let (operation, method) = methods.get(&id).cloned().unwrap_or_default();
+ let total = totals
+ .entry((row["direction"].to_string(), operation, method))
+ .or_default();
+ total.0 += 1;
+ total.1 += row["wire_bytes"].as_u64().unwrap();
+ }
+ self.emit(row);
+ }
+ for ((direction, operation, method), (frames, bytes)) in totals {
+ self.emit(json!({"kind":"wire_summary","direction":direction,"remote_kind":operation,"method":method,"frames":frames,"wire_bytes":bytes}));
+ }
+ self.phases.clear();
+ }
+ pub fn finish(&mut self) {
+ let after = hash();
+ if after != self.binary {
+ self.failures += 1;
+ }
+ self.context = json!({});
+ self.emit(
+ json!({"kind":"suite_end","failures":self.failures,"binary_sha256_before":self.binary,
+ "binary_sha256_after":after,"rss_pass_threshold":null}),
+ );
+ }
+}
+fn hash() -> String {
+ format!(
+ "{:x}",
+ Sha256::digest(std::fs::read(env!("CARGO_BIN_EXE_maka")).unwrap())
+ )
+}
diff --git a/crates/cli/tests/integration/tui/memory_acceptance/sampler.rs b/crates/cli/tests/integration/tui/memory_acceptance/sampler.rs
new file mode 100644
index 0000000000..f39fe25072
--- /dev/null
+++ b/crates/cli/tests/integration/tui/memory_acceptance/sampler.rs
@@ -0,0 +1,144 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use std::sync::{
+ Arc, Mutex,
+ atomic::{AtomicBool, AtomicU32, Ordering},
+};
+
+struct State {
+ phase: &'static str,
+ iteration: usize,
+ rows: Vec<Value>,
+ exhausted: bool,
+}
+
+pub(super) struct Sampler {
+ host: u32,
+ tui: Arc<AtomicU32>,
+ origin: Instant,
+ state: Arc<Mutex<State>>,
+ stop: Arc<AtomicBool>,
+ task: Option<std::thread::JoinHandle<()>>,
+}
+impl Sampler {
+ pub fn new(host: u32, origin: Instant) -> Self {
+ let state = Arc::new(Mutex::new(State {
+ phase: "host_startup",
+ iteration: 0,
+ rows: Vec::new(),
+ exhausted: false,
+ }));
+ let stop = Arc::new(AtomicBool::new(false));
+ let tui = Arc::new(AtomicU32::new(0));
+ let (reading, stopped, terminal) = (state.clone(), stop.clone(), tui.clone());
+ let task = std::thread::spawn(move || {
+ while !stopped.load(Ordering::Acquire) {
+ capture(
+ host,
+ terminal.load(Ordering::Acquire),
+ origin,
+ &reading,
+ "periodic",
+ );
+ std::thread::park_timeout(Duration::from_millis(10));
+ }
+ });
+ Self {
+ host,
+ tui,
+ origin,
+ state,
+ stop,
+ task: Some(task),
+ }
+ }
+ pub fn set_tui(&self, pid: u32) {
+ self.tui.store(pid, Ordering::Release);
+ self.mark("tui_startup", 0);
+ }
+ pub fn mark(&self, phase: &'static str, iteration: usize) {
+ {
+ let mut state = self.state.lock().unwrap();
+ state.phase = phase;
+ state.iteration = iteration;
+ }
+ capture(
+ self.host,
+ self.tui.load(Ordering::Acquire),
+ self.origin,
+ &self.state,
+ "checkpoint",
+ );
+ }
+ pub fn stop(&mut self) -> Vec<Value> {
+ self.join();
+ std::mem::take(&mut self.state.lock().unwrap().rows)
+ }
+ fn join(&mut self) {
+ self.stop.store(true, Ordering::Release);
+ if let Some(task) = self.task.take() {
+ task.thread().unpark();
+ task.join().expect("RSS sampler thread");
+ }
+ }
+}
+impl Drop for Sampler {
+ fn drop(&mut self) {
+ self.join();
+ }
+}
+
+fn capture(host: u32, tui: u32, origin: Instant, state: &Mutex<State>, kind: &str) {
+ let mut state = state.lock().unwrap();
+ if state.rows.len() >= 200_000 {
+ if !state.exhausted {
+ state.exhausted = true;
+ state.rows.push(
+ json!({"kind":"rss_sampler_error", "error":"200000 sample evidence limit reached"}),
+ );
+ }
+ return;
+ }
+ for (process, pid) in [("host", host), ("tui", tui)] {
+ if pid == 0 {
+ continue;
+ }
+ let mut row = json!({"kind":"rss","sample_kind":kind,"at_ns":origin.elapsed().as_nanos() as u64,
+ "process":process,"pid":pid,"phase":state.phase,"iteration":state.iteration});
+ match std::fs::read_to_string(format!("/proc/{pid}/status")) {
+ Ok(text) => {
+ for (field, name) in [("rss_kib", "VmRSS:"), ("hwm_kib", "VmHWM:")] {
+ row[field] = text
+ .lines()
+ .find(|line| line.starts_with(name))
+ .and_then(|line| line.split_whitespace().nth(1))
+ .and_then(|v| v.parse::<u64>().ok())
+ .map_or(Value::Null, Value::from);
+ }
+ if row["rss_kib"].is_null() || row["hwm_kib"].is_null() {
+ row["error"] = json!("missing VmRSS/VmHWM");
+ }
+ }
+ Err(error) => row["error"] = json!(error.to_string()),
+ }
+ state.rows.push(row);
+ }
+}
diff --git a/crates/cli/tests/integration/tui/navigation.rs b/crates/cli/tests/integration/tui/navigation.rs
index b23ec7b4d3..01d6772501 100644
--- a/crates/cli/tests/integration/tui/navigation.rs
+++ b/crates/cli/tests/integration/tui/navigation.rs
@@ -69,7 +69,10 @@ fn restart_keeps_forward_history_and_keyboard_control_without_replaying_actions(
reopened.send(b"\x1b[1;3D");
reopened.wait_for("ASCII v");
reopened.send(b"\x1b[1;3D");
- reopened.wait_for("No sessions yet");
+ reopened.wait_for("Maka dark v"); // Category selection is a shell history entry.
+ reopened.send(b"\x1b[1;3D");
+ reopened
+ .wait_until(|screen| screen.contains("No sessions yet") && !screen.contains("Maka dark v"));
reopened.host_details();
reopened.wait_for("Host epoch:");
reopened.send(b"\x1b[1;3C"); // New destination replaces the previous forward branch.
diff --git a/crates/cli/tests/integration/tui/plugins.rs b/crates/cli/tests/integration/tui/plugins.rs
index 3e8d3c7bc9..9232daedcd 100644
--- a/crates/cli/tests/integration/tui/plugins.rs
+++ b/crates/cli/tests/integration/tui/plugins.rs
@@ -24,11 +24,18 @@ use maka_protocol::{
};
use serde_json::json;
-fn package(directory: &std::path::Path, id: &str, title: &str, source: &str) -> std::path::PathBuf {
+fn package(
+ directory: &std::path::Path,
+ id: &str,
+ title: &str,
+ source: &str,
+ ui: (&str, &str),
+) -> std::path::PathBuf {
let path = directory.join(id);
std::fs::create_dir(&path).unwrap();
std::fs::write(path.join("host.mjs"), source).unwrap();
- std::fs::write(path.join("maka.extension.json"),json!({"schemaVersion":1,"id":id,"displayName":title,"runtime":{"entry":"host.mjs","sdkVersion":1}}).to_string()).unwrap();
+ std::fs::write(path.join(ui.0), ui.1).unwrap();
+ std::fs::write(path.join("maka.extension.json"),json!({"schemaVersion":1,"id":id,"displayName":title,"runtime":{"entry":"host.mjs","sdkVersion":2}}).to_string()).unwrap();
path
}
fn host(directory: &std::path::Path) -> super::super::candidate::CandidateFixture {
@@ -191,6 +198,10 @@ fn local_plugin_management_installs_board_and_owns_instance_lifecycle_through_th
"example.board",
"Managed Board",
include_str!("../../fixtures/board-plugin/host.mjs"),
+ (
+ "board-ui.mjs",
+ include_str!("../../fixtures/board-plugin/board-ui.mjs"),
+ ),
);
let host = host(directory.path());
let runtime = tokio::runtime::Runtime::new().unwrap();
@@ -277,6 +288,10 @@ fn plugin_config_draft_survives_conflict_and_changes_real_plugin_behavior_after_
"example.configured",
"Configured greeting",
include_str!("../../fixtures/configured-plugin/host.mjs"),
+ (
+ "configured-ui.mjs",
+ include_str!("../../fixtures/configured-plugin/configured-ui.mjs"),
+ ),
);
let host = host(directory.path());
let runtime = tokio::runtime::Runtime::new().unwrap();
diff --git a/crates/cli/tests/integration/tui/reading.rs b/crates/cli/tests/integration/tui/reading.rs
index 66485d64f0..c656ab4177 100644
--- a/crates/cli/tests/integration/tui/reading.rs
+++ b/crates/cli/tests/integration/tui/reading.rs
@@ -151,7 +151,7 @@ fn old_window_selection_and_search_resume_across_pages_and_disk_restart() {
.unwrap(),
)
.unwrap();
- assert_eq!(saved["version"], 20);
+ assert_eq!(saved["version"], 22);
let encoded = serde_json::to_string(&saved).unwrap();
assert!(
!encoded.contains("History context line"),
diff --git a/crates/cli/tests/integration/tui/recovery.rs b/crates/cli/tests/integration/tui/recovery.rs
index d3c220891f..603d87b8fc 100644
--- a/crates/cli/tests/integration/tui/recovery.rs
+++ b/crates/cli/tests/integration/tui/recovery.rs
@@ -261,7 +261,7 @@ impl LostReply {
assert_eq!(original["origin_epoch"], value["input"]["originHostEpoch"]);
assert_eq!(original["session"], value["input"]["sessionId"]);
assert_eq!(original["id"], value["input"]["messageId"]);
- assert_eq!(saved["version"], 20);
+ assert_eq!(saved["version"], 22);
assert_eq!(original["content"], value["input"]["content"]);
assert_eq!(original["placement"], value["input"]["placement"]);
assert_eq!(original["input_selections"], value["input"].get("inputSelections").cloned().unwrap_or_else(|| json!({})));
diff --git a/crates/cli/tests/integration/tui/references.rs b/crates/cli/tests/integration/tui/references.rs
index 3b22871e92..6662654b06 100644
--- a/crates/cli/tests/integration/tui/references.rs
+++ b/crates/cli/tests/integration/tui/references.rs
@@ -103,7 +103,7 @@ fn host_directory_reference_survives_restart_and_sends_without_text_or_registrat
.join(&client.identity.root_id)
.join("default/state.json");
let saved: Value = serde_json::from_slice(&std::fs::read(&state).unwrap()).unwrap();
- assert_eq!(saved["version"], 20);
+ assert_eq!(saved["version"], 22);
assert_eq!(
saved["directories"]["refs"],
json!([{"hostId":client.identity.root_id,"path":selected}])
diff --git a/crates/cli/tests/integration/tui/revision.rs b/crates/cli/tests/integration/tui/revision.rs
index b83a4f0e58..e3a964f1d8 100644
--- a/crates/cli/tests/integration/tui/revision.rs
+++ b/crates/cli/tests/integration/tui/revision.rs
@@ -149,7 +149,7 @@ fn revision_edits_ordered_inputs_preserves_attachments_and_reopens_without_resub
tui.close_terminal();
tui.finish();
let saved: Value = serde_json::from_slice(&std::fs::read(&checkpoint).unwrap()).unwrap();
- assert_eq!(saved["version"], 20);
+ assert_eq!(saved["version"], 22);
assert_eq!(saved["revision"]["stage"], "draft");
assert!(saved["attachments"].as_object().unwrap().is_empty());
for input in saved["revision"]["inputs"].as_array().unwrap() {
diff --git a/crates/cli/tests/integration/tui/skills.rs b/crates/cli/tests/integration/tui/skills.rs
index d6533f9392..74e3f8dc2c 100644
--- a/crates/cli/tests/integration/tui/skills.rs
+++ b/crates/cli/tests/integration/tui/skills.rs
@@ -105,7 +105,7 @@ fn skills_candidates_page_without_execution_and_restore_exact_selection_before_s
.join(&client.identity.root_id)
.join("default/state.json");
let saved: Value = serde_json::from_slice(&std::fs::read(&checkpoint).unwrap()).unwrap();
- assert_eq!(saved["version"], 20);
+ assert_eq!(saved["version"], 22);
let picked = saved["skills"]["skills"].as_array().unwrap();
assert_eq!(picked.len(), 1);
assert_eq!(picked[0]["name"], "Candidate 128");
diff --git a/crates/cli/tests/integration/tui/workhub.rs b/crates/cli/tests/integration/tui/workhub.rs
index e08eed5ba3..d9fd41b532 100644
--- a/crates/cli/tests/integration/tui/workhub.rs
+++ b/crates/cli/tests/integration/tui/workhub.rs
@@ -27,6 +27,7 @@ use serde_json::{Value, json};
mod composition;
mod manual;
mod model;
+mod navigation;
mod readback;
mod setup;
@@ -94,7 +95,7 @@ fn new_root_delegates_repairs_the_same_task_and_composes_a_runtime_js_filler() {
tui.click_page_text("Note draft");
tui.send(b"\x1b[Z\r");
tui.wait_for("Find a model or executor");
- tui.click_last_text("‹ WorkHub");
+ tui.click_last_text("‹ Back");
tui.wait_for("Note draft");
tui.click_page_text("Note draft");
tui.send(b"\x1b[Z\t");
@@ -103,7 +104,7 @@ fn new_root_delegates_repairs_the_same_task_and_composes_a_runtime_js_filler() {
// Remove only the selected task model. The coordinator remains available.
runtime.block_on(readback::remove_worker_model(&client));
- tui.click_last_text("‹ WorkHub");
+ tui.click_last_text("‹ Back");
tui.wait_for("Coordinator conversation");
setup::delegate(&mut tui, "beta");
let beta = runtime.block_on(readback::assignment(&client, "Task beta", false));
@@ -160,7 +161,7 @@ fn new_root_delegates_repairs_the_same_task_and_composes_a_runtime_js_filler() {
);
tui.click_page_text("Save note");
tui.wait_until(|s| s.contains("Saved note: beta private draft"));
- tui.click_last_text("‹ WorkHub");
+ tui.click_last_text("‹ Back");
setup::select_task(&mut tui, "Task alpha");
tui.wait_for("alpha private draft");
assert!(
diff --git a/crates/cli/tests/integration/tui/workhub/composition.rs b/crates/cli/tests/integration/tui/workhub/composition.rs
index af2fe8aa5e..1aeb39d69b 100644
--- a/crates/cli/tests/integration/tui/workhub/composition.rs
+++ b/crates/cli/tests/integration/tui/workhub/composition.rs
@@ -30,9 +30,14 @@ pub(super) async fn install(client: &maka_client::Client, directory: &Path) {
include_str!("../../../fixtures/workhub-notes-plugin/host.mjs"),
)
.unwrap();
+ std::fs::write(
+ package.join("notes-ui.mjs"),
+ include_str!("../../../fixtures/workhub-notes-plugin/notes-ui.mjs"),
+ )
+ .unwrap();
std::fs::write(
package.join("maka.extension.json"),
- json!({"schemaVersion":1,"id":PACKAGE,"runtime":{"entry":"host.mjs","sdkVersion":1}})
+ json!({"schemaVersion":1,"id":PACKAGE,"runtime":{"entry":"host.mjs","sdkVersion":2}})
.to_string(),
)
.unwrap();
diff --git a/crates/cli/tests/integration/tui/workhub/navigation.rs b/crates/cli/tests/integration/tui/workhub/navigation.rs
new file mode 100644
index 0000000000..e30cfd4a39
--- /dev/null
+++ b/crates/cli/tests/integration/tui/workhub/navigation.rs
@@ -0,0 +1,174 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+
+const PACKAGE: &str = "example.nested-navigation";
+
+#[test]
+fn rust_parent_and_installed_js_child_restore_exact_routes_and_independent_drafts() {
+ let directory = tempfile::tempdir().unwrap();
+ let mut host =
+ super::super::super::candidate::CandidateFixture::new(directory.path().join("root"));
+ host.child = Some(
+ Command::new(env!("CARGO_BIN_EXE_maka"))
+ .args(["host", "serve", "--root"])
+ .arg(&host.root)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::inherit())
+ .spawn()
+ .unwrap(),
+ );
+ host.wait_for_registration();
+ let runtime = tokio::runtime::Runtime::new().unwrap();
+ let (client, provider) = runtime.block_on(async {
+ (
+ support::client(&host.root).await,
+ model::Provider::start().await,
+ )
+ });
+ let mut tui = Pty::spawn_at(
+ &["--root", host.root.to_str().unwrap()],
+ Some(directory.path()),
+ );
+ tui.resize(170, 48);
+ setup::connection(&mut tui, &runtime, &client, &provider.url);
+ setup::hub(&mut tui, &runtime, &client, directory.path());
+ setup::delegate(&mut tui, "alpha");
+ let assignment = runtime.block_on(readback::assignment(&client, "Task alpha", true));
+ let origin = json!({
+ "assignmentId": assignment["operationId"],
+ "sourceSessionId": readback::source_session(&assignment)
+ });
+ setup::open_task(&mut tui, "Task alpha");
+ let package = directory.path().join("nested-navigation-plugin");
+ std::fs::create_dir(&package).unwrap();
+ std::fs::write(
+ package.join("host.mjs"),
+ include_str!("../../../fixtures/nested-navigation-plugin/host.mjs"),
+ )
+ .unwrap();
+ std::fs::write(
+ package.join("nested-ui.mjs"),
+ include_str!("../../../fixtures/nested-navigation-plugin/nested-ui.mjs"),
+ )
+ .unwrap();
+ std::fs::write(
+ package.join("maka.extension.json"),
+ json!({
+ "schemaVersion": 1, "id": PACKAGE, "runtime": {"entry": "host.mjs", "sdkVersion": 2}
+ })
+ .to_string(),
+ )
+ .unwrap();
+ runtime.block_on(async {
+ client.request(Operation::PluginPackageInstall, json!({"sourcePath": package})).await.unwrap();
+ client.request(Operation::PluginCompositionApply, json!({"operations": [{
+ "type": "insert", "rootId": "profile", "entry": {"id": "nested-navigation", "packageId": PACKAGE}
+ }]})).await.unwrap();
+ });
+ tui.wait_for("Base note draft");
+ tui.click_page_text("Base note draft");
+ tui.send(b"\x1b[200~base private draft\x1b[201~");
+ tui.wait_for("base private draft");
+ tui.click_page_text("Note details");
+ tui.wait_for("Detail note draft");
+ tui.click_page_text("Detail note draft");
+ tui.send(b"\x1b[200~detail private draft\x1b[201~");
+ tui.wait_for("detail private draft");
+ tui.close_terminal();
+ tui.finish();
+
+ let checkpoint = directory
+ .path()
+ .join("tui-state")
+ .join(&client.identity.root_id)
+ .join("default/state.json");
+ let saved: Value = serde_json::from_slice(&std::fs::read(&checkpoint).unwrap()).unwrap();
+ let drafts: Vec<_> = saved["apps"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .filter(|item| item["key"]["package"] == PACKAGE)
+ .collect();
+ assert_eq!(drafts.len(), 2);
+ for text in ["base private draft", "detail private draft"] {
+ let draft = drafts
+ .iter()
+ .find(|item| item["drafts"]["note"] == text)
+ .unwrap();
+ assert_eq!(draft["key"]["origin"], origin);
+ assert_eq!(draft["key"]["within"][0]["package"], "maka.workhub");
+ assert_eq!(
+ draft["key"]["within"][0]["route"]["assignment"],
+ assignment["operationId"]
+ );
+ }
+ let details = json!({"context": origin, "pane": "details"});
+ let location =
+ &saved["navigation"]["entries"][saved["navigation"]["cursor"].as_u64().unwrap() as usize];
+ assert_eq!(location["embedded"][0]["route"], details);
+ assert_eq!(
+ runtime.block_on(readback::remote(&client, PACKAGE, "writes", Value::Null)),
+ 0
+ );
+
+ let mut tui = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+ tui.resize(170, 48);
+ tui.wait_for("detail private draft");
+ tui.wait_for("Resume draft");
+ tui.click_page_text("Resume draft");
+ tui.wait_for("Draft ready");
+ assert_eq!(
+ runtime.block_on(readback::remote(&client, PACKAGE, "writes", Value::Null)),
+ 0
+ );
+ tui.send(b"\x1b[1;3D");
+ tui.wait_for("base private draft");
+ tui.wait_for("Resume draft");
+ tui.click_page_text("Resume draft");
+ tui.wait_for("Draft ready");
+ tui.send(b"\x1b[1;3C");
+ tui.wait_for("detail private draft");
+ tui.click_page_text("Save nested note");
+ tui.wait_for("Stored: detail private draft");
+ tui.close_terminal();
+ tui.finish();
+
+ // The selected child is now clean. Its navigated route still survives.
+ let mut tui = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+ tui.resize(170, 48);
+ tui.wait_for("Detail note draft");
+ tui.wait_for("Stored: detail private draft");
+ tui.send(b"\x1b[1;3D");
+ tui.wait_for("base private draft");
+ tui.close_terminal();
+ tui.finish();
+ assert_eq!(
+ runtime.block_on(readback::remote(&client, PACKAGE, "writes", Value::Null)),
+ 1
+ );
+ assert_eq!(provider.executed.lock().unwrap().as_slice(), ["alpha"]);
+ assert_eq!(provider.delegated.lock().unwrap().len(), 1);
+ provider.task.abort();
+ client.disconnect();
+ host.retire_registered();
+ assert!(host.wait_for_exit().success());
+}
diff --git a/crates/cli/tests/integration/tui/workhub/setup.rs b/crates/cli/tests/integration/tui/workhub/setup.rs
index 23cb68b7eb..660444eb8b 100644
--- a/crates/cli/tests/integration/tui/workhub/setup.rs
+++ b/crates/cli/tests/integration/tui/workhub/setup.rs
@@ -47,6 +47,7 @@ pub(super) fn connection(
.unwrap()
.descriptor
.label;
+ tui.wait_until(|screen| !screen.contains("Loading providers…") && screen.contains(" ▾"));
tui.click_text(" ▾");
let choices = ["○", "●"].map(|marker| format!("{marker} {label}"));
tui.wait_until(|s| choices.iter().any(|choice| s.contains(choice)));
@@ -188,7 +189,7 @@ pub(super) fn open_task(tui: &mut Pty, title: &str) {
&tui.screen.snapshot().unwrap().screen,
"Coordinator conversation",
) {
- tui.click_last_text("‹ WorkHub");
+ tui.click_last_text("‹ Back");
wait_page_text(tui, "Coordinator conversation");
}
select_task(tui, title);
@@ -232,7 +233,11 @@ pub(super) fn ascii(tui: &mut Pty) {
tui.wait_for("○ ASCII");
tui.send(b"\x1b[B\r");
tui.wait_for("ASCII v");
- open(tui);
+ tui.send(b"\x1b[1;3D");
+ tui.wait_for("Model connections");
+ // Both Settings categories are real locations. The second Back restores
+ // the selected task and its nested view without reopening its default list.
+ tui.send(b"\x1b[1;3D");
}
pub(super) fn localized(root: &Path, locale: &str, coordinator: &str, setup: &str) {
diff --git a/crates/js-runtime/src/plugin.rs b/crates/js-runtime/src/plugin.rs
index 59a7cc519c..225b34190a 100644
--- a/crates/js-runtime/src/plugin.rs
+++ b/crates/js-runtime/src/plugin.rs
@@ -116,7 +116,7 @@ enum Command {
Load {
id: u64,
bridge: Option<Arc<dyn Bridge>>,
- plugin: bool,
+ bootstrap: Bootstrap,
name: String,
source: String,
state: watch::Sender<State>,
@@ -143,6 +143,13 @@ enum Command {
},
}
+#[derive(Clone, Copy)]
+enum Bootstrap {
+ Plain,
+ Plugin,
+ Presenter,
+}
+
struct Health {
isolate: OnceLock<v8::IsolateHandle>,
failure: Mutex<Option<Error>>,
@@ -237,7 +244,7 @@ impl Vm {
source: String,
bridge: Option<Arc<dyn Bridge>>,
) -> Result<Module> {
- self.load_bound(name, source, bridge, false)
+ self.load_bound(name, source, bridge, Bootstrap::Plain)
}
pub fn load_plugin(
@@ -246,7 +253,18 @@ impl Vm {
source: String,
bridge: Arc<dyn Bridge>,
) -> Result<Module> {
- self.load_bound(name, source, Some(bridge), true)
+ self.load_bound(name, source, Some(bridge), Bootstrap::Plugin)
+ }
+
+ /// A terminal page has its own factory and capability surface. It never
+ /// runs the business plugin activation bootstrap.
+ pub fn load_presenter(
+ &self,
+ name: String,
+ source: String,
+ bridge: Arc<dyn Bridge>,
+ ) -> Result<Module> {
+ self.load_bound(name, source, Some(bridge), Bootstrap::Presenter)
}
fn load_bound(
@@ -254,7 +272,7 @@ impl Vm {
name: String,
source: String,
bridge: Option<Arc<dyn Bridge>>,
- plugin: bool,
+ bootstrap: Bootstrap,
) -> Result<Module> {
if name.len() > 1024
|| source.len() > 8 * 1024 * 1024
@@ -285,7 +303,7 @@ impl Vm {
.send(Command::Load {
id,
bridge,
- plugin,
+ bootstrap,
name,
source,
state,
diff --git a/crates/js-runtime/src/plugin/engine.rs b/crates/js-runtime/src/plugin/engine.rs
index 8c796397a5..1d9612bdcd 100644
--- a/crates/js-runtime/src/plugin/engine.rs
+++ b/crates/js-runtime/src/plugin/engine.rs
@@ -16,14 +16,18 @@
* specific language governing permissions and limitations
* under the License.
*/
-use super::{Health, Limits, Result, failed};
+use super::{Bootstrap, Health, Limits, Result, failed};
use deno_core::{JsRuntime, RuntimeOptions, v8};
use futures_util::future::LocalBoxFuture;
use serde_json::Value;
use std::sync::Arc;
pub(super) struct Module(pub v8::Global<v8::Object>);
-struct Sdk(v8::Global<v8::Function>);
+struct Sdk {
+ plugin: v8::Global<v8::Function>,
+ presenter: v8::Global<v8::Function>,
+ terminal: v8::Global<v8::Object>,
+}
pub(super) type Call = LocalBoxFuture<'static, Result<v8::Global<v8::Value>>>;
pub(super) fn runtime(health: &Arc<Health>, limits: &Limits) -> Result<JsRuntime> {
@@ -70,16 +74,41 @@ pub(super) fn runtime(health: &Arc<Health>, limits: &Limits) -> Result<JsRuntime
}"#,
)
.map_err(failed)?;
+ let terminal = runtime
+ .execute_script(
+ "maka-terminal-builders",
+ include_str!("terminal-builders.js"),
+ )
+ .map_err(failed)?;
+ let terminal = {
+ deno_core::scope!(scope, runtime);
+ let terminal =
+ v8::Local::<v8::Object>::try_from(v8::Local::new(scope, terminal)).map_err(failed)?;
+ v8::Global::new(scope, terminal)
+ };
let sdk = runtime
.execute_script("maka-plugin-sdk", include_str!("sdk.js"))
.map_err(failed)?;
- let sdk = {
+ let plugin = {
deno_core::scope!(scope, runtime);
let sdk =
v8::Local::<v8::Function>::try_from(v8::Local::new(scope, sdk)).map_err(failed)?;
- Sdk(v8::Global::new(scope, sdk))
+ v8::Global::new(scope, sdk)
};
- runtime.op_state().borrow_mut().put(sdk);
+ let presenter = runtime
+ .execute_script("maka-terminal-presenter", include_str!("presenter.js"))
+ .map_err(failed)?;
+ let presenter = {
+ deno_core::scope!(scope, runtime);
+ let presenter = v8::Local::<v8::Function>::try_from(v8::Local::new(scope, presenter))
+ .map_err(failed)?;
+ v8::Global::new(scope, presenter)
+ };
+ runtime.op_state().borrow_mut().put(Sdk {
+ plugin,
+ presenter,
+ terminal,
+ });
Ok(runtime)
}
@@ -87,9 +116,19 @@ pub(super) fn load(
runtime: &mut JsRuntime,
name: &str,
source: &str,
- plugin: Option<u64>,
+ bootstrap: Bootstrap,
+ id: u64,
) -> Result<Module> {
- let sdk = runtime.op_state().borrow().borrow::<Sdk>().0.clone();
+ let sdk = {
+ let state = runtime.op_state();
+ let state = state.borrow();
+ let sdk = state.borrow::<Sdk>();
+ match bootstrap {
+ Bootstrap::Plain => None,
+ Bootstrap::Plugin => Some((sdk.plugin.clone(), sdk.terminal.clone())),
+ Bootstrap::Presenter => Some((sdk.presenter.clone(), sdk.terminal.clone())),
+ }
+ };
deno_core::scope!(scope, runtime);
v8::tc_scope!(scope, scope);
let resource =
@@ -151,13 +190,18 @@ pub(super) fn load(
}
let mut namespace =
v8::Local::<v8::Object>::try_from(module.get_module_namespace()).map_err(failed)?;
- if let Some(id) = plugin {
+ if let Some((sdk, terminal)) = sdk {
let sdk = v8::Local::new(scope, sdk);
+ let terminal = v8::Local::new(scope, terminal);
let key = v8::String::new(scope, &id.to_string())
.ok_or_else(|| failed("SDK identity allocation failed"))?;
let undefined = v8::undefined(scope).into();
let value = sdk
- .call(scope, undefined, &[namespace.into(), key.into()])
+ .call(
+ scope,
+ undefined,
+ &[namespace.into(), key.into(), terminal.into()],
+ )
.ok_or_else(|| {
failed(
scope
diff --git a/crates/js-runtime/src/plugin/presenter.js b/crates/js-runtime/src/plugin/presenter.js
new file mode 100644
index 0000000000..339724a615
--- /dev/null
+++ b/crates/js-runtime/src/plugin/presenter.js
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+(namespace, key, { builders, pick }) => {
+ if (typeof namespace.default !== 'function') {
+ throw new TypeError('A terminal entry must export a default page factory');
+ }
+ let initialized;
+ let closed = false;
+ const calls = new Map();
+ return Object.freeze({
+ async invoke(input, authority) {
+ if (closed || typeof authority !== 'string' || calls.has(authority)) {
+ throw new Error('Terminal invocation is retired or invalid');
+ }
+ let aborted = false;
+ let wake;
+ let used = false;
+ const cancelled = new Promise((resolve) => {
+ wake = resolve;
+ });
+ const cancel = () => {
+ aborted = true;
+ wake();
+ };
+ const signal = Object.freeze({
+ get aborted() {
+ return aborted || closed;
+ },
+ wait: () => cancelled,
+ throwIfAborted() {
+ if (this.aborted) throw new Error('Terminal invocation cancelled');
+ },
+ });
+ calls.set(authority, cancel);
+ try {
+ initialized ??= Promise.resolve().then(() =>
+ namespace.default(Object.freeze({ tui: builders })),
+ );
+ const handlers = await initialized;
+ signal.throwIfAborted();
+ const locale = input?.locale ?? 'en';
+ const cx = Object.freeze({
+ locale,
+ t: (en, zhCN, zhTW) => pick(locale, en, zhCN, zhTW),
+ signal,
+ backend: (...args) => {
+ if (args.length) throw new TypeError('backend() accepts no arguments');
+ if (!calls.has(authority)) throw new Error('Terminal invocation is retired');
+ signal.throwIfAborted();
+ if (used) throw new Error('backend() may be called at most once');
+ used = true;
+ return Deno.core.ops.op_maka_plugin(key, 'page.backend', { authority });
+ },
+ });
+ switch (input?.kind) {
+ case 'read': {
+ const result = await handlers.read(input.route ?? null, cx);
+ return { kind: 'view', view: result?.version ? result : builders.view(result) };
+ }
+ case 'submit': {
+ const { route = null, revision, action, fields = {}, grant = null } = input;
+ return await handlers.submit({ route, revision, action, fields, grant }, cx);
+ }
+ case 'recover':
+ if (typeof handlers.recover !== 'function') {
+ throw new TypeError('This view declares no recovery');
+ }
+ return await handlers.recover(input.route ?? null, cx);
+ default:
+ throw new TypeError('Unknown terminal request');
+ }
+ } finally {
+ calls.delete(authority);
+ cancel();
+ }
+ },
+ cancel(authority) {
+ if (authority !== undefined) calls.get(authority)?.();
+ else {
+ closed = true;
+ for (const cancel of calls.values()) cancel();
+ }
+ },
+ });
+};
diff --git a/crates/js-runtime/src/plugin/sdk.js b/crates/js-runtime/src/plugin/sdk.js
index 8c5b734a00..e6cc49b829 100644
--- a/crates/js-runtime/src/plugin/sdk.js
+++ b/crates/js-runtime/src/plugin/sdk.js
@@ -17,7 +17,7 @@
* under the License.
*/
-(namespace, key) => {
+(namespace, key, { builders, pick }) => {
const activate = namespace.default;
if (typeof activate !== 'function') {
throw new TypeError('A Host plugin must export a default activation function');
@@ -266,6 +266,12 @@
return id;
};
const register = async (kind, definition, fn) => {
+ if (
+ (kind === 'remote_method' || kind === 'remote_stream') &&
+ Object.hasOwn(definition, 'terminalView')
+ ) {
+ throw new TypeError('Remote registrations cannot declare terminalView; use tui.app()');
+ }
if (
!['loading', 'prepared', 'active'].includes(phase) ||
(phase === 'loading' && registrations.length >= 128)
@@ -366,7 +372,7 @@
}
};
- // The store owns source data; each Remote document owns one immutable snapshot.
+ // The business store owns data; each document mount owns an immutable snapshot.
// No page call can allocate a snapshot or borrow another document's fence.
const transcriptResource = async (remote, name, initial = {}, options) => {
const encoder = new TextEncoder();
@@ -546,11 +552,21 @@
timings.set(value.turn, value);
if (timings.size > 4096) fail('Transcript timing capacity exceeded');
}
- const owner = (caller) => {
+ const owner = (caller, token) => {
if (!caller?.documentId || !caller?.clientInstanceId)
fail('Missing Remote document identity');
caller.signal.throwIfAborted();
- return JSON.stringify([caller.clientInstanceId, caller.documentId, caller.sessionId]);
+ if (
+ typeof token !== 'string' ||
+ !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(token)
+ )
+ fail('Invalid transcript mount UUID');
+ return JSON.stringify([
+ caller.clientInstanceId,
+ caller.documentId,
+ caller.sessionId,
+ token.toLowerCase(),
+ ]);
};
const recordAt = (entry, offset) => {
if (offset === 0 && entry.encoded.length < 48 * 1024)
@@ -606,14 +622,14 @@
return true;
};
const page = (input, caller) => {
- const mount = mounts.get(owner(caller));
+ const mount = mounts.get(owner(caller, input?.mount));
if (closed || !mount || input?.resource !== name || input.fence !== mount.fence)
fail('Transcript snapshot is no longer available', 'revoked');
counters.pageReads++;
return mount.page(input);
};
const open = (input, caller) => {
- const document = owner(caller);
+ const document = owner(caller, input?.mount);
if (closed) fail('Transcript resource is closed', 'revoked');
if (
input?.resource !== name ||
@@ -623,7 +639,7 @@
bytes(input.locale) > 32
)
fail('Invalid transcript open');
- if (mounts.has(document) || mounts.size >= 4) fail('Transcript document capacity exceeded');
+ if (mounts.has(document) || mounts.size >= 4) fail('Transcript mount capacity exceeded');
const fence = revision;
let snapshot = [...sources.values()];
let snapshotTimings = new Map(timings);
@@ -783,10 +799,16 @@
close: () => mount.cancel(),
};
};
- const reader = await remote.method(resource.read, page, options);
+ const reader = await remote.method(resource.read, page, {
+ ...options,
+ observation: { role: 'transcript_read', resource },
+ });
let streamer;
try {
- streamer = await remote.stream(resource.stream, open, options);
+ streamer = await remote.stream(resource.stream, open, {
+ ...options,
+ observation: { role: 'transcript_stream', resource },
+ });
} catch (error) {
await reader.close();
throw error;
@@ -861,70 +883,36 @@
});
};
- /** Terminal apps: a Remote method that answers terminal view requests,
- * with builders for the view tree the terminal renders. */
+ /** Business registrations plus the shared, capability-free view builders. */
const terminal = (remote) => {
- const pick = (locale, en, zhCN, zhTW) => {
- const language = String(locale ?? '').toLowerCase();
- if (language === 'zh-tw' || language === 'zh-hant') return zhTW ?? zhCN ?? en;
- if (language.startsWith('zh')) return zhCN ?? en;
- return en;
- };
- const view = ({ title, revision, fields = [], actions = [], root }) => ({
- version: 5,
- title,
- revision: String(revision),
- fields,
- actions,
- root,
- });
- const item = (key, title, target, extra = {}) => ({
- kind: 'item',
- key,
- title,
- target,
- ...extra,
- });
- const text = (key, value, tone = 'normal') => ({
- kind: 'text',
- key,
- spans: [{ text: String(value), tone }],
- });
return Object.freeze({
- /** Serves `handlers` as the terminal view `descriptor` describes. */
- app: (name, handlers, descriptor, options = {}) =>
- remote.method(
- name,
- async (input, caller) => {
- const locale = input?.locale ?? 'en';
- const cx = Object.freeze({
- locale,
- caller,
- t: (en, zhCN, zhTW) => pick(locale, en, zhCN, zhTW),
- });
- switch (input?.kind) {
- case 'read': {
- const result = await handlers.read(input.route ?? null, cx);
- return { kind: 'view', view: result?.version ? result : view(result) };
- }
- case 'submit': {
- const { route = null, revision, action, fields = {}, grant = null } = input;
- return await handlers.submit({ route, revision, action, fields, grant }, cx);
- }
- case 'recover': {
- if (typeof handlers.recover !== 'function') {
- throw Object.assign(new Error('This view declares no recovery'), {
- code: 'invalid',
- });
- }
- return await handlers.recover(input.route ?? null, cx);
- }
- default:
- throw Object.assign(new Error('Unknown terminal request'), { code: 'invalid' });
- }
- },
- { ...options, terminalView: { version: 5, ...descriptor } },
- ),
+ ...builders,
+ /** Captures a private backend and immutable, document-owned UI entry. */
+ app: async (name, definition, descriptor, options = {}) => {
+ const { entry, backend, resources = [] } = definition ?? {};
+ if (typeof entry !== 'string' || !entry || typeof backend !== 'function') {
+ throw new TypeError(
+ 'Terminal apps require an entry and private backend; inline handlers are unsupported',
+ );
+ }
+ if (!Array.isArray(resources)) throw new TypeError('Invalid terminal app resources');
+ return register(
+ 'terminal_app',
+ { ...options, name, entry, resources, terminalView: { ...descriptor, version: 7 } },
+ (input, caller) =>
+ remoteResult(() => {
+ const locale = input?.locale ?? 'en';
+ return backend(
+ input,
+ Object.freeze({
+ locale,
+ caller,
+ t: (en, zhCN, zhTW) => pick(locale, en, zhCN, zhTW),
+ }),
+ );
+ }),
+ );
+ },
/** A changes stream for a descriptor's `changes`; calling the
* returned function tells every open view it is stale. */
changes: async (name) => {
@@ -968,61 +956,6 @@
},
transcriptResource: (name, initial, options) =>
transcriptResource(remote, name, initial, options),
- transcript: (key, resource) => ({ kind: 'transcript', key, resource }),
- view,
- column: (key, children, gap = 1) => ({ kind: 'column', key, gap, children }),
- stack: (key, children) => ({ kind: 'column', key, gap: 0, children }),
- row: (key, children, gap = 2) => ({ kind: 'row', key, gap, children }),
- text,
- spans: (key, spans) => ({
- kind: 'text',
- key,
- spans: spans.map(([value, tone = 'normal']) => ({ text: String(value), tone })),
- }),
- heading: (key, value) => text(key, value, 'strong'),
- rule: (key) => ({ kind: 'rule', key }),
- scroll: (key, rows, child) => ({ kind: 'scroll', key, rows, child }),
- split: (key, ratio, left, right) => ({ kind: 'split', key, ratio, left, right }),
- tabs: (key, current, tabs) => ({ kind: 'tabs', key, current, tabs }),
- link: (key, title, route, extra) => item(key, title, { kind: 'route', route }, extra),
- act: (key, title, action, extra) => item(key, title, { kind: 'action', action }, extra),
- open: (key, title, session, extra) => item(key, title, { kind: 'session', session }, extra),
- button: (key, action, role = 'normal', label) => ({
- kind: 'button',
- key,
- action,
- role,
- ...(label === undefined ? {} : { label }),
- }),
- input: (key, field, label = '') => ({ kind: 'input', key, field, label }),
- progress: (key, value, max, label = '') => ({ kind: 'progress', key, value, max, label }),
- markdown: (key, value) => ({ kind: 'markdown', key, text: String(value) }),
- code: (key, value) => ({ kind: 'code', key, text: String(value) }),
- slot: (key, name, context = null) => ({ kind: 'slot', key, name, context }),
- action: (id, label, extra = {}) => ({ id, label, ...extra }),
- toggle: (id, value) => ({ id, control: { kind: 'toggle', value: Boolean(value) } }),
- line: (id, value = '', maxBytes = 256, extra = {}) => ({
- id,
- control: { kind: 'text', value: String(value), max_bytes: maxBytes, ...extra },
- }),
- area: (id, value = '', maxBytes = 8192, extra = {}) => ({
- id,
- control: {
- kind: 'text',
- value: String(value),
- max_bytes: maxBytes,
- multiline: true,
- ...extra,
- },
- }),
- choice: (id, value, options) => ({
- id,
- control: {
- kind: 'choice',
- value,
- options: options.map(([option, label]) => ({ value: option, label })),
- },
- }),
});
};
return Object.freeze({
diff --git a/crates/js-runtime/src/plugin/terminal-builders.js b/crates/js-runtime/src/plugin/terminal-builders.js
new file mode 100644
index 0000000000..4df53d7935
--- /dev/null
+++ b/crates/js-runtime/src/plugin/terminal-builders.js
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+(() => {
+ const pick = (locale, en, zhCN, zhTW) => {
+ const language = String(locale ?? '').toLowerCase();
+ if (language === 'zh-tw' || language === 'zh-hant') return zhTW ?? zhCN ?? en;
+ if (language.startsWith('zh')) return zhCN ?? en;
+ return en;
+ };
+ const view = ({ title, revision, fields = [], actions = [], root }) => ({
+ version: 7,
+ title,
+ revision: String(revision),
+ fields,
+ actions,
+ root,
+ });
+ const item = (key, title, target, extra = {}) => ({
+ kind: 'item',
+ key,
+ title,
+ target,
+ ...extra,
+ });
+ const text = (key, value, tone = 'normal') => ({
+ kind: 'text',
+ key,
+ spans: [{ text: String(value), tone }],
+ });
+ const builders = Object.freeze({
+ transcript: (key, resource) => ({ kind: 'transcript', key, resource }),
+ view,
+ column: (key, children, gap = 1) => ({ kind: 'column', key, gap, children }),
+ stack: (key, children) => ({ kind: 'column', key, gap: 0, children }),
+ row: (key, children, gap = 2) => ({ kind: 'row', key, gap, children }),
+ boundary: (key, body, { bottom, padding, emphasis = 'normal', activity = 'idle' } = {}) => ({
+ kind: 'boundary',
+ key,
+ body,
+ ...(bottom === undefined ? {} : { bottom }),
+ padding: { horizontal: 0, vertical: 0, ...padding },
+ emphasis,
+ activity,
+ }),
+ text,
+ spans: (key, spans) => ({
+ kind: 'text',
+ key,
+ spans: spans.map(([value, tone = 'normal']) => ({ text: String(value), tone })),
+ }),
+ heading: (key, value) => text(key, value, 'strong'),
+ rule: (key) => ({ kind: 'rule', key }),
+ scroll: (key, rows, child) => ({ kind: 'scroll', key, rows, child }),
+ split: (key, ratio, left, right) => ({ kind: 'split', key, ratio, left, right }),
+ tabs: (key, current, tabs) => ({ kind: 'tabs', key, current, tabs }),
+ link: (key, title, route, extra) => item(key, title, { kind: 'route', route }, extra),
+ act: (key, title, action, extra) => item(key, title, { kind: 'action', action }, extra),
+ open: (key, title, session, extra) => item(key, title, { kind: 'session', session }, extra),
+ button: (key, action, role = 'normal', label) => ({
+ kind: 'button',
+ key,
+ action,
+ role,
+ ...(label === undefined ? {} : { label }),
+ }),
+ input: (key, field, label = '') => ({ kind: 'input', key, field, label }),
+ progress: (key, value, max, label = '') => ({ kind: 'progress', key, value, max, label }),
+ markdown: (key, value) => ({ kind: 'markdown', key, text: String(value) }),
+ code: (key, value) => ({ kind: 'code', key, text: String(value) }),
+ slot: (key, name, context = null) => ({ kind: 'slot', key, name, context }),
+ action: (id, label, extra = {}) => ({ id, label, ...extra }),
+ toggle: (id, value) => ({ id, control: { kind: 'toggle', value: Boolean(value) } }),
+ line: (id, value = '', maxBytes = 256, extra = {}) => ({
+ id,
+ control: { kind: 'text', value: String(value), max_bytes: maxBytes, ...extra },
+ }),
+ area: (id, value = '', maxBytes = 8192, extra = {}) => ({
+ id,
+ control: {
+ kind: 'text',
+ value: String(value),
+ max_bytes: maxBytes,
+ multiline: true,
+ ...extra,
+ },
+ }),
+ choice: (id, value, options) => ({
+ id,
+ control: {
+ kind: 'choice',
+ value,
+ options: options.map(([option, label]) => ({ value: option, label })),
+ },
+ }),
+ });
+ return Object.freeze({ pick, builders });
+})();
diff --git a/crates/js-runtime/src/plugin/worker.rs b/crates/js-runtime/src/plugin/worker.rs
index 80f3432b3e..1a9cadebc4 100644
--- a/crates/js-runtime/src/plugin/worker.rs
+++ b/crates/js-runtime/src/plugin/worker.rs
@@ -70,14 +70,14 @@ pub(super) async fn run(
Command::Load {
id,
bridge,
- plugin,
+ bootstrap,
name,
source,
state,
slot,
bytes,
} => {
- match engine::load(&mut runtime, &name, &source, plugin.then_some(id)) {
+ match engine::load(&mut runtime, &name, &source, bootstrap, id) {
Ok(code) => {
if let Some(bridge) = bridge {
runtime
diff --git a/crates/plugins/src/package/manifest.rs b/crates/plugins/src/package/manifest.rs
index 79f2348b70..0521ed7d1e 100644
--- a/crates/plugins/src/package/manifest.rs
+++ b/crates/plugins/src/package/manifest.rs
@@ -24,7 +24,7 @@ use std::collections::BTreeSet;
use super::validate_path;
use crate::{Error, identifier};
-pub const SDK_VERSION: u32 = 1;
+pub const SDK_VERSION: u32 = 2;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
diff --git a/crates/plugins/src/remote.rs b/crates/plugins/src/remote.rs
index bd5d0c75e2..6d41bfc032 100644
--- a/crates/plugins/src/remote.rs
+++ b/crates/plugins/src/remote.rs
@@ -116,6 +116,12 @@ pub enum Error {
}
pub trait Method: Send + Sync {
+ /// An isolated terminal presenter is created once per Remote document.
+ /// Opening must only reserve ownership and queue initialization, never
+ /// execute UI code or wait for it. Ordinary Rust methods need no factory.
+ fn page_factory(&self) -> Option<Arc<dyn crate::terminal_ui::presenter::Factory>> {
+ None
+ }
fn call(&self, input: Value, caller: Caller) -> BoxFuture<'static, Result<Value, Error>>;
}
pub trait StreamProvider: Send + Sync {
@@ -140,6 +146,8 @@ pub enum Handler {
pub struct Endpoint {
pub access: Access,
+ /// Typed source metadata; it does not grant membership in any terminal app.
+ pub observation: Option<crate::terminal_ui::presenter::ObservationRole>,
/// Present when a plugin frontend may bind this endpoint by package bytes.
pub content_digest: Option<String>,
pub handler: Handler,
@@ -159,6 +167,7 @@ impl Endpoint {
pub fn new(content_digest: String, handler: Handler) -> Self {
Self {
access: Access::Granted,
+ observation: None,
content_digest: Some(content_digest),
handler,
registration: Uuid::new_v4(),
@@ -169,6 +178,7 @@ impl Endpoint {
pub fn standalone(handler: Handler) -> Self {
Self {
access: Access::Granted,
+ observation: None,
content_digest: None,
handler,
registration: Uuid::new_v4(),
diff --git a/crates/plugins/src/terminal_ui.rs b/crates/plugins/src/terminal_ui.rs
index d6f3d2faa0..96695ef864 100644
--- a/crates/plugins/src/terminal_ui.rs
+++ b/crates/plugins/src/terminal_ui.rs
@@ -23,6 +23,7 @@
pub mod app;
pub mod page;
+pub mod presenter;
pub mod transcript;
pub mod view;
@@ -30,7 +31,7 @@ use crate::Error;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
-pub const VERSION: u32 = 5;
+pub const VERSION: u32 = 7;
/// Presentation text is distinct from the stable identity used for navigation.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
diff --git a/crates/plugins/src/terminal_ui/presenter.rs b/crates/plugins/src/terminal_ui/presenter.rs
new file mode 100644
index 0000000000..81cb266a1c
--- /dev/null
+++ b/crates/plugins/src/terminal_ui/presenter.rs
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Host-owned execution for one terminal document. A factory belongs to a
+//! registration; its pages belong to documents, never the business activation.
+
+use super::transcript::Resource;
+use crate::remote::{Caller, Error, Target};
+use futures_util::future::BoxFuture;
+use serde_json::Value;
+use std::sync::Arc;
+use tokio_util::sync::CancellationToken;
+
+pub trait Factory: Send + Sync {
+ /// Exact source registrations captured when this app was published.
+ fn observations(&self) -> &[Observation] {
+ &[]
+ }
+ /// Reserve ownership before asynchronous module initialization starts.
+ fn open(&self, cancellation: CancellationToken) -> Result<Arc<dyn Page>, Error>;
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Observation {
+ pub method: String,
+ pub target: Target,
+ pub role: ObservationRole,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum ObservationRole {
+ ChangesStream,
+ TranscriptRead(Resource),
+ TranscriptStream(Resource),
+}
+
+pub trait Page: Send + Sync {
+ fn call(&self, input: Value, caller: Caller) -> BoxFuture<'static, Result<Value, Error>>;
+ /// Revoke this page synchronously. Cleanup confirmation is separate.
+ fn cancel(&self);
+ /// Page execution may retire even after returning a valid backend receipt.
+ fn retired(&self) -> BoxFuture<'_, ()> {
+ Box::pin(std::future::pending())
+ }
+ fn close(&self) -> BoxFuture<'_, Result<(), Error>>;
+}
diff --git a/crates/plugins/src/terminal_ui/transcript/resource.rs b/crates/plugins/src/terminal_ui/transcript/resource.rs
index 1d8fc361da..b3c37e8420 100644
--- a/crates/plugins/src/terminal_ui/transcript/resource.rs
+++ b/crates/plugins/src/terminal_ui/transcript/resource.rs
@@ -17,15 +17,15 @@
* under the License.
*/
-//! A mount owns one Remote document and one stream. The first item is Ready:
-//! it captures a snapshot and subscribes atomically. Page reads use that fence;
-//! later stream revisions are contiguous. Cancellation closes the document.
+//! A mount borrows its page's Remote document and owns one stream. Ready
+//! captures a snapshot and subscribes atomically. Page reads use that fence;
+//! later stream revisions are contiguous. Cancellation closes only the stream.
use super::{Block, Key, Timing};
use serde::{Deserialize, Serialize};
use serde_json::Value;
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Resource {
pub id: String,
@@ -40,6 +40,7 @@ pub struct Resource {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Open {
+ pub mount: uuid::Uuid,
pub resource: String,
pub route: Value,
pub locale: String,
@@ -57,6 +58,7 @@ pub enum Direction {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Read {
+ pub mount: uuid::Uuid,
pub resource: String,
pub fence: u64,
pub direction: Direction,
@@ -136,3 +138,32 @@ pub enum Event {
/// update stream. The reader must close and open a fresh resource.
Invalidated,
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ #[test]
+ fn source_requests_require_an_independent_uuid_mount() {
+ let open = json!({"resource":"activity", "route":null, "locale":"en"});
+ let read = json!({"resource":"activity", "fence":1, "direction":"tail"});
+ assert!(serde_json::from_value::<Open>(open.clone()).is_err());
+ assert!(serde_json::from_value::<Read>(read.clone()).is_err());
+ for mount in [json!(null), json!("not-a-uuid"), json!(123)] {
+ let mut open = open.clone();
+ let mut read = read.clone();
+ open["mount"] = mount.clone();
+ read["mount"] = mount;
+ assert!(serde_json::from_value::<Open>(open).is_err());
+ assert!(serde_json::from_value::<Read>(read).is_err());
+ }
+ let mount = uuid::Uuid::new_v4();
+ let mut open = open;
+ let mut read = read;
+ open["mount"] = json!(mount);
+ read["mount"] = json!(mount);
+ assert_eq!(serde_json::from_value::<Open>(open).unwrap().mount, mount);
+ assert_eq!(serde_json::from_value::<Read>(read).unwrap().mount, mount);
+ }
+}
diff --git a/crates/plugins/src/terminal_ui/view.rs b/crates/plugins/src/terminal_ui/view.rs
index 012bcb5f75..c78af015cf 100644
--- a/crates/plugins/src/terminal_ui/view.rs
+++ b/crates/plugins/src/terminal_ui/view.rs
@@ -38,9 +38,44 @@ pub const MAX_FIELDS: usize = 32;
pub const MAX_ACTIONS: usize = 32;
pub const MAX_TEXT: usize = 16 * 1024;
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(
+ Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
+)]
+#[serde(rename_all = "snake_case")]
+pub enum Emphasis {
+ #[default]
+ Normal,
+ Accent,
+}
+
+#[derive(
+ Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
+)]
+#[serde(rename_all = "snake_case")]
+pub enum Activity {
+ #[default]
+ Idle,
+ Busy,
+}
+
+/// Interior space on each axis, bounded to four cells by View validation.
+#[derive(
+ Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
+)]
+#[serde(deny_unknown_fields)]
+pub struct Padding {
+ #[serde(default)]
+ #[schemars(range(max = 4))]
+ pub horizontal: u8,
+ #[serde(default)]
+ #[schemars(range(max = 4))]
+ pub vertical: u8,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct View {
+ #[schemars(range(min = VERSION, max = VERSION))]
pub version: u32,
pub title: String,
/// Opaque business revision, echoed with every submission for CAS.
@@ -52,7 +87,7 @@ pub struct View {
pub root: Node,
}
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Field {
pub id: String,
@@ -65,7 +100,7 @@ fn yes() -> bool {
true
}
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Control {
Toggle {
@@ -89,14 +124,14 @@ pub enum Control {
},
}
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Choice {
pub value: String,
pub label: String,
}
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Action {
pub id: String,
@@ -115,7 +150,7 @@ pub struct Action {
pub confirm: Option<Confirm>,
}
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Confirm {
pub title: String,
@@ -125,7 +160,9 @@ pub struct Confirm {
}
/// What a text span means; the theme decides how it looks.
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(
+ Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
+)]
#[serde(rename_all = "snake_case")]
pub enum Tone {
#[default]
@@ -139,7 +176,9 @@ pub enum Tone {
Error,
}
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(
+ Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
+)]
#[serde(rename_all = "snake_case")]
pub enum Role {
#[default]
@@ -148,7 +187,7 @@ pub enum Role {
Destructive,
}
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Span {
pub text: String,
@@ -157,7 +196,7 @@ pub struct Span {
}
/// What activating an item or button does.
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Target {
/// Read another route of this view; Back returns.
@@ -168,7 +207,7 @@ pub enum Target {
Session { session: String },
}
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Tab {
pub id: String,
@@ -176,7 +215,7 @@ pub struct Tab {
pub route: Value,
}
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Node {
Column {
@@ -191,6 +230,19 @@ pub enum Node {
gap: u8,
children: Vec<Node>,
},
+ /// A framed body and an optional single row in its lower border.
+ Boundary {
+ key: String,
+ body: Box<Node>,
+ #[serde(default)]
+ bottom: Option<Box<Node>>,
+ #[serde(default)]
+ padding: Padding,
+ #[serde(default)]
+ emphasis: Emphasis,
+ #[serde(default)]
+ activity: Activity,
+ },
/// Wrapped prose, or one clipped line.
Text {
key: String,
@@ -291,6 +343,7 @@ impl Node {
match self {
Self::Column { key, .. }
| Self::Row { key, .. }
+ | Self::Boundary { key, .. }
| Self::Text { key, .. }
| Self::Rule { key }
| Self::Scroll { key, .. }
@@ -311,6 +364,11 @@ impl Node {
match self {
Self::Column { children, .. } | Self::Row { children, .. } => children.iter().collect(),
Self::Scroll { child, .. } => vec![child],
+ Self::Boundary { body, bottom, .. } => {
+ let mut children = vec![body.as_ref()];
+ children.extend(bottom.as_deref());
+ children
+ }
Self::Split { left, right, .. } => vec![left, right],
_ => vec![],
}
@@ -527,7 +585,7 @@ impl View {
}
}
let mut walk = Walk::default();
- self.node(&self.root, 0, &fields, &actions, &mut walk)?;
+ self.node(&self.root, 0, false, &fields, &actions, &mut walk)?;
bounded(self, MAX_BYTES - 64)
}
@@ -535,6 +593,7 @@ impl View {
&self,
node: &'a Node,
depth: usize,
+ inline: bool,
fields: &BTreeSet<&str>,
actions: &BTreeSet<&str>,
walk: &mut Walk<'a>,
@@ -544,7 +603,33 @@ impl View {
return Err(invalid());
}
key(node.key())?;
+ if inline
+ && !matches!(
+ node,
+ Node::Row { .. } | Node::Text { .. } | Node::Button { .. }
+ )
+ {
+ return Err(invalid());
+ }
match node {
+ Node::Boundary {
+ body,
+ bottom,
+ padding,
+ ..
+ } => {
+ if padding.horizontal > 4
+ || padding.vertical > 4
+ || bottom.as_ref().is_some_and(|node| node.key() == body.key())
+ {
+ return Err(invalid());
+ }
+ self.node(body, depth + 1, false, fields, actions, walk)?;
+ if let Some(bottom) = bottom {
+ self.node(bottom, depth + 1, true, fields, actions, walk)?;
+ }
+ return Ok(());
+ }
Node::Column { gap, children, .. } | Node::Row { gap, children, .. } => {
// Children count against the view's node budget, not a
// container's: a checklist may hold its whole list.
@@ -564,7 +649,7 @@ impl View {
}
// Long prose is bounded by the view's own budget.
for span in spans {
- prose(&span.text, MAX_BYTES, true)?;
+ prose(&span.text, MAX_BYTES, !inline)?;
}
}
Node::Rule { .. } => {}
@@ -658,7 +743,7 @@ impl View {
}
}
for child in node.children() {
- self.node(child, depth + 1, fields, actions, walk)?;
+ self.node(child, depth + 1, inline, fields, actions, walk)?;
}
Ok(())
}
@@ -791,6 +876,52 @@ impl Reply {
pub mod build {
use super::*;
+ pub fn boundary(key: impl Into<String>, body: Node) -> Boundary {
+ Boundary(Node::Boundary {
+ key: key.into(),
+ body: Box::new(body),
+ bottom: None,
+ padding: Padding::default(),
+ emphasis: Emphasis::Normal,
+ activity: Activity::Idle,
+ })
+ }
+ pub struct Boundary(Node);
+ impl Boundary {
+ pub fn bottom(mut self, node: Node) -> Self {
+ if let Node::Boundary { bottom, .. } = &mut self.0 {
+ *bottom = Some(Box::new(node));
+ }
+ self
+ }
+ pub fn padding(mut self, horizontal: u8, vertical: u8) -> Self {
+ if let Node::Boundary { padding, .. } = &mut self.0 {
+ *padding = Padding {
+ horizontal,
+ vertical,
+ };
+ }
+ self
+ }
+ pub fn emphasis(mut self, value: Emphasis) -> Self {
+ if let Node::Boundary { emphasis, .. } = &mut self.0 {
+ *emphasis = value;
+ }
+ self
+ }
+ pub fn activity(mut self, value: Activity) -> Self {
+ if let Node::Boundary { activity, .. } = &mut self.0 {
+ *activity = value;
+ }
+ self
+ }
+ }
+ impl From<Boundary> for Node {
+ fn from(boundary: Boundary) -> Self {
+ boundary.0
+ }
+ }
+
pub fn column(key: impl Into<String>, children: Vec<Node>) -> Node {
Node::Column {
key: key.into(),
@@ -1077,6 +1208,9 @@ pub mod build {
}
}
+#[cfg(test)]
+mod boundary;
+
#[cfg(test)]
mod tests {
use super::{build::*, *};
diff --git a/crates/plugins/src/terminal_ui/view/boundary.rs b/crates/plugins/src/terminal_ui/view/boundary.rs
new file mode 100644
index 0000000000..277cbe6fcd
--- /dev/null
+++ b/crates/plugins/src/terminal_ui/view/boundary.rs
@@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{build::*, *};
+use serde_json::json;
+
+fn fixture() -> View {
+ serde_json::from_str(include_str!(
+ "../../../../../packages/plugin-sdk/tests/fixtures/terminal-boundary.json"
+ ))
+ .unwrap()
+}
+
+#[test]
+fn boundary_rust_and_js_share_a_view_with_an_input_and_bottom_action() {
+ let view = View {
+ version: VERSION,
+ title: "Review".into(),
+ revision: "r1".into(),
+ fields: vec![area("note", "Hello", 4096)],
+ actions: vec![Action {
+ fields: vec!["note".into()],
+ ..action("save", "Save")
+ }],
+ root: boundary("review", input("note", "note", "Note"))
+ .bottom(row(
+ "meta",
+ vec![
+ text("status", "中文 🦀", Tone::Muted),
+ button("save", "save", Role::Primary),
+ ],
+ ))
+ .padding(1, 0)
+ .emphasis(Emphasis::Accent)
+ .activity(Activity::Busy)
+ .into(),
+ };
+ assert_eq!(view, fixture(), "one wire fixture is consumed by both SDKs");
+ view.validate().unwrap();
+ assert_eq!(
+ view.root
+ .children()
+ .iter()
+ .map(|node| node.key())
+ .collect::<Vec<_>>(),
+ ["note", "meta"]
+ );
+ let fields = BTreeMap::from([("note".into(), json!("Updated\n中文"))]);
+ let request = view
+ .submission(json!(null), "save", fields.clone(), "zh-CN".into())
+ .unwrap();
+ assert!(
+ matches!(request, Request::Submit { revision, action, fields: submitted, .. }
+ if revision == "r1" && action == "save" && submitted == fields)
+ );
+ assert!(
+ view.submission(json!(null), "save", BTreeMap::new(), "en".into())
+ .is_err()
+ );
+ let restored: View = serde_json::from_value(serde_json::to_value(&view).unwrap()).unwrap();
+ assert_eq!(restored, view);
+}
+
+#[test]
+fn boundary_bottom_only_accepts_single_line_content_and_declared_actions() {
+ let rejected = |node| {
+ let mut view = fixture();
+ if let Node::Boundary { bottom, .. } = &mut view.root {
+ *bottom = Some(Box::new(node));
+ }
+ assert!(view.validate().is_err(), "{view:?}");
+ };
+ for node in [
+ input("other", "note", "Note"),
+ column("meta", vec![text("line", "Text", Tone::Normal)]),
+ scroll("meta", 1, text("line", "Text", Tone::Normal)),
+ boundary("meta", text("line", "Text", Tone::Normal)).into(),
+ text("meta", "two\nlines", Tone::Normal),
+ row("meta", vec![text("nested", "tab\tvalue", Tone::Normal)]),
+ row("meta", vec![button("missing", "missing", Role::Normal)]),
+ text("note", "duplicate body key", Tone::Normal),
+ ] {
+ rejected(node);
+ }
+ let mut view = fixture();
+ if let Node::Boundary { bottom, .. } = &mut view.root {
+ *bottom = Some(Box::new(row(
+ "meta",
+ vec![row(
+ "nested",
+ vec![
+ button("save", "save", Role::Normal),
+ button("again", "save", Role::Normal),
+ ],
+ )],
+ )));
+ }
+ view.validate().unwrap();
+ view.root = column("outer", vec![view.root, input("duplicate", "note", "Note")]);
+ assert!(
+ view.validate().is_err(),
+ "a field inside a boundary is still mounted only once"
+ );
+}
+
+#[test]
+fn boundary_children_share_tree_depth_node_byte_and_padding_budgets() {
+ for padding in [
+ Padding {
+ horizontal: 5,
+ vertical: 0,
+ },
+ Padding {
+ horizontal: 0,
+ vertical: 5,
+ },
+ ] {
+ let mut view = fixture();
+ if let Node::Boundary { padding: value, .. } = &mut view.root {
+ *value = padding;
+ }
+ assert!(view.validate().is_err());
+ }
+ let mut view = fixture();
+ view.root = boundary(
+ "limit",
+ column(
+ "body",
+ (0..MAX_NODES - 3).map(|n| rule(format!("r{n}"))).collect(),
+ ),
+ )
+ .bottom(text("bottom", "At node limit", Tone::Normal))
+ .padding(4, 4)
+ .into();
+ view.validate().unwrap();
+ if let Node::Boundary { body, .. } = &mut view.root
+ && let Node::Column { children, .. } = body.as_mut()
+ {
+ children.push(rule("overflow"));
+ }
+ assert!(
+ view.validate().is_err(),
+ "bottom counts against the same node budget"
+ );
+ let mut deep = text("leaf", "Deep", Tone::Normal);
+ for n in 0..MAX_DEPTH {
+ deep = row(format!("r{n}"), vec![deep]);
+ }
+ view.root = boundary("depth", rule("body")).bottom(deep).into();
+ assert!(
+ view.validate().is_err(),
+ "nested bottom rows share the depth budget"
+ );
+ view.root = boundary(
+ "bytes",
+ text("body", "a".repeat(MAX_BYTES / 2), Tone::Normal),
+ )
+ .bottom(text("bottom", "b".repeat(MAX_BYTES / 2), Tone::Normal))
+ .into();
+ assert!(
+ view.validate().is_err(),
+ "body and bottom share the serialized byte budget"
+ );
+}
+
+#[test]
+fn boundary_wire_defaults_are_closed_and_versioned() {
+ let mut value = serde_json::to_value(fixture()).unwrap();
+ value["root"] =
+ json!({"kind":"boundary", "key":"minimal", "body":{"kind":"rule", "key":"body"}});
+ let view: View = serde_json::from_value(value.clone()).unwrap();
+ view.validate().unwrap();
+ assert!(matches!(
+ view.root,
+ Node::Boundary {
+ bottom: None,
+ padding: Padding {
+ horizontal: 0,
+ vertical: 0
+ },
+ emphasis: Emphasis::Normal,
+ activity: Activity::Idle,
+ ..
+ }
+ ));
+ for (name, invalid) in [
+ ("activity", json!("spin")),
+ ("emphasis", json!("red")),
+ ("padding", json!({"left":1})),
+ ("execute", json!("save")),
+ ] {
+ let mut invalid_value = value.clone();
+ invalid_value["root"][name] = invalid;
+ assert!(serde_json::from_value::<View>(invalid_value).is_err());
+ }
+ value["version"] = json!(VERSION - 1);
+ assert!(
+ serde_json::from_value::<View>(value)
+ .unwrap()
+ .validate()
+ .is_err()
+ );
+}
+
+#[test]
+fn boundary_view_schema_carries_closed_options_and_padding_bounds() {
+ let schema = serde_json::to_value(schemars::schema_for!(View)).unwrap();
+ let validator = jsonschema::validator_for(&schema).unwrap();
+ let value = serde_json::to_value(fixture()).unwrap();
+ validator.validate(&value).unwrap();
+ for (field, replacement) in [
+ ("emphasis", json!("red")),
+ ("activity", json!("flash")),
+ ("padding", json!({"horizontal": 5})),
+ ("padding", json!({"vertical": 5})),
+ ("padding", json!({"left": 1})),
+ ] {
+ let mut invalid = value.clone();
+ invalid["root"][field] = replacement;
+ assert!(!validator.is_valid(&invalid));
+ }
+ let mut old = value;
+ old["version"] = json!(VERSION - 1);
+ assert!(!validator.is_valid(&old));
+}
diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs
index 10f7ea60f0..fad50f08a0 100644
--- a/crates/protocol/src/lib.rs
+++ b/crates/protocol/src/lib.rs
@@ -60,7 +60,7 @@ use serde::Serialize;
use serde_json::Value;
pub const PROTOCOL_VERSION: u64 = 0;
-pub const COMPATIBILITY_EPOCH: u64 = 192;
+pub const COMPATIBILITY_EPOCH: u64 = 194;
pub const COMPOSITION_ID: &str = "maka.interactive";
pub const MAX_MESSAGE_BYTES: usize = 768 * 1024;
pub const MAX_IN_FLIGHT_DOMAIN_REQUESTS: usize = 64;
diff --git a/crates/runtime-host/src/plugins/javascript.rs b/crates/runtime-host/src/plugins/javascript.rs
index ef58214e13..7076b942b8 100644
--- a/crates/runtime-host/src/plugins/javascript.rs
+++ b/crates/runtime-host/src/plugins/javascript.rs
@@ -25,6 +25,7 @@ mod executor;
mod input;
mod invocation;
mod model;
+mod presenter;
mod provider;
mod registration;
mod remote;
@@ -46,12 +47,17 @@ use std::time::Duration;
pub(crate) struct Loader {
pool: Arc<Pool>,
+ presenters: Arc<presenter::Capacity>,
executions: Weak<Executions>,
}
impl Loader {
pub fn new(executions: &Arc<Executions>) -> Result<Self, maka_plugins::Error> {
Ok(Self {
pool: Arc::new(Pool::new(Limits::default(), 4).map_err(invalid)?),
+ presenters: Arc::new(presenter::Capacity::new(Limits {
+ synchronous_slice: Duration::from_millis(200),
+ ..Limits::default()
+ })),
executions: Arc::downgrade(executions),
})
}
@@ -75,8 +81,14 @@ impl PackageLoader for Loader {
mode: entry.vm,
generation: format!("{}:{}", package.manifest().id, package.digest()),
remote: Arc::new(remote::Source {
- package_id: package.manifest().id.clone(),
- content_digest: package.digest().into(),
+ package: package.clone(),
+ presenters: self.presenters.clone(),
+ catalog: self
+ .executions
+ .upgrade()
+ .ok_or_else(|| invalid("Host closed"))?
+ .plugin_catalog
+ .clone(),
}),
}))
})();
diff --git a/crates/runtime-host/src/plugins/javascript/presenter.rs b/crates/runtime-host/src/plugins/javascript/presenter.rs
new file mode 100644
index 0000000000..b4afd7409f
--- /dev/null
+++ b/crates/runtime-host/src/plugins/javascript/presenter.rs
@@ -0,0 +1,243 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+mod bridge;
+
+use futures_util::future::BoxFuture;
+use maka_js_runtime::plugin::{Limits, Module, Vm};
+use maka_plugins::{
+ package::{Package, validate_path},
+ remote::{Caller, Error, Method},
+ terminal_ui::presenter as api,
+};
+use serde_json::Value;
+use std::{sync::Arc, time::Duration};
+use tokio::sync::{Semaphore, watch};
+use tokio_util::sync::CancellationToken;
+
+/// One Host-wide admission budget, independent of business Dedicated VMs.
+/// Four bounds simultaneous page heaps; hidden drafts do not reserve a slot.
+pub(super) struct Capacity {
+ slots: Arc<Semaphore>,
+ limits: Limits,
+}
+impl Capacity {
+ pub fn new(limits: Limits) -> Self {
+ Self {
+ slots: Arc::new(Semaphore::new(4)),
+ limits,
+ }
+ }
+}
+pub(super) struct Factory {
+ source: String,
+ name: String,
+ capacity: Arc<Capacity>,
+ observations: Vec<api::Observation>,
+ backend: Arc<dyn Method>,
+ retiring: CancellationToken,
+}
+impl Factory {
+ pub fn new(
+ package: &Package,
+ entry: &str,
+ capacity: Arc<Capacity>,
+ backend: Arc<dyn Method>,
+ retiring: CancellationToken,
+ observations: Vec<api::Observation>,
+ ) -> Result<Self, Error> {
+ validate_path(entry).map_err(invalid)?;
+ let bytes = package
+ .file(entry)
+ .ok_or_else(|| invalid("terminal entry is missing"))?;
+ let source = std::str::from_utf8(bytes).map_err(invalid)?.to_owned();
+ Ok(Self {
+ source,
+ name: format!("{}:{entry}", package.digest()),
+ capacity,
+ observations,
+ backend,
+ retiring,
+ })
+ }
+}
+impl api::Factory for Factory {
+ fn observations(&self) -> &[api::Observation] {
+ &self.observations
+ }
+ fn open(&self, cancellation: CancellationToken) -> Result<Arc<dyn api::Page>, Error> {
+ if cancellation.is_cancelled() || self.retiring.is_cancelled() {
+ return Err(Error::Retired);
+ }
+ let reservation = self
+ .capacity
+ .slots
+ .clone()
+ .try_acquire_owned()
+ .map_err(|_| Error::Provider("terminal page VM capacity exhausted".into()))?;
+ let vm = Vm::new(self.capacity.limits.clone()).map_err(provider)?;
+ let bridge = Arc::new(bridge::PageBridge::new(self.backend.clone()));
+ let module = match vm.load_presenter(self.name.clone(), self.source.clone(), bridge.clone())
+ {
+ Ok(module) => module,
+ Err(error) => {
+ tokio::spawn(async move {
+ vm.shutdown().await;
+ drop(reservation);
+ });
+ return Err(provider(error));
+ }
+ };
+ let stop = cancellation.child_token();
+ let (done, closed) = watch::channel(None);
+ let cancelling = module.clone();
+ let page = Arc::new(Page(Arc::new(Inner {
+ module,
+ bridge: bridge.clone(),
+ stop: stop.clone(),
+ closed,
+ })));
+ let retiring = self.retiring.clone();
+ tokio::spawn(async move {
+ tokio::select! {
+ _ = stop.cancelled() => {},
+ _ = retiring.cancelled() => {},
+ _ = vm.failed() => {},
+ }
+ stop.cancel();
+ let ids = bridge.cancel();
+ // Cooperative signals are best effort; worker teardown remains the
+ // proof of UI cleanup even when a continuation ignores its signal.
+ let _ = tokio::time::timeout(Duration::from_millis(200), async {
+ for id in ids {
+ let _ = cancelling.cancel_call(id).await;
+ }
+ })
+ .await;
+ // A permit remains held until actual worker teardown, even if a
+ // caller stops waiting. VM death is not unknown Host cleanup.
+ vm.shutdown().await;
+ let result = bridge.drained().await;
+ drop(reservation);
+ done.send_replace(Some(result));
+ });
+ Ok(page)
+ }
+}
+struct Page(Arc<Inner>);
+struct Inner {
+ module: Module,
+ bridge: Arc<bridge::PageBridge>,
+ stop: CancellationToken,
+ closed: watch::Receiver<Option<Result<(), Error>>>,
+}
+impl Drop for Inner {
+ fn drop(&mut self) {
+ self.stop.cancel();
+ }
+}
+impl Inner {
+ async fn closed(&self) -> Result<(), Error> {
+ let mut closed = self.closed.clone();
+ let wait = async {
+ loop {
+ if let Some(result) = closed.borrow_and_update().clone() {
+ return result;
+ }
+ closed
+ .changed()
+ .await
+ .map_err(|_| Error::CleanupUnconfirmed)?;
+ }
+ };
+ tokio::time::timeout(Duration::from_secs(5), wait)
+ .await
+ .map_err(|_| Error::CleanupUnconfirmed)?
+ }
+}
+impl api::Page for Page {
+ fn call(&self, input: Value, caller: Caller) -> BoxFuture<'static, Result<Value, Error>> {
+ let page = self.0.clone();
+ Box::pin(async move {
+ if page.stop.is_cancelled() {
+ return Err(Error::Retired);
+ }
+ let cancellation = caller.cancellation.clone();
+ let guard = page.bridge.enter(&input, caller)?;
+ let call = page.module.call(
+ vec!["invoke".into()],
+ vec![input, Value::String(guard.id.clone())],
+ );
+ tokio::pin!(call);
+ let result = tokio::select! {
+ biased;
+ _ = cancellation.cancelled() => Err(Error::Cancelled),
+ _ = page.stop.cancelled() => Err(Error::Retired),
+ result = &mut call => result.map_err(provider),
+ };
+ let failed = result.is_err();
+ let result = guard.resolve(result);
+ if failed || result.is_err() {
+ page.stop.cancel();
+ let closed = page.closed().await;
+ // Receipt authority and cleanup confirmation are independent.
+ // Backend completion during teardown still settles this call.
+ if let Some(outcome) = guard.outcome() {
+ return Ok(outcome);
+ }
+ closed?;
+ }
+ result
+ })
+ }
+ fn cancel(&self) {
+ self.0.stop.cancel();
+ self.0.bridge.cancel();
+ }
+ fn retired(&self) -> BoxFuture<'_, ()> {
+ Box::pin(self.0.stop.cancelled())
+ }
+ fn close(&self) -> BoxFuture<'_, Result<(), Error>> {
+ self.cancel();
+ Box::pin(self.0.closed())
+ }
+}
+/// Only the document dispatcher can invoke this registration's private backend.
+pub(super) struct Endpoint(pub Arc<Factory>);
+impl Method for Endpoint {
+ fn page_factory(&self) -> Option<Arc<dyn api::Factory>> {
+ Some(self.0.clone())
+ }
+ fn call(&self, _: Value, _: Caller) -> BoxFuture<'static, Result<Value, Error>> {
+ Box::pin(async {
+ Err(Error::Invalid(
+ "terminal app requires an isolated document".into(),
+ ))
+ })
+ }
+}
+fn invalid(error: impl ToString) -> Error {
+ Error::Invalid(error.to_string())
+}
+fn provider(error: impl ToString) -> Error {
+ Error::Provider(error.to_string())
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/runtime-host/src/plugins/javascript/presenter/bridge.rs b/crates/runtime-host/src/plugins/javascript/presenter/bridge.rs
new file mode 100644
index 0000000000..74c3865c35
--- /dev/null
+++ b/crates/runtime-host/src/plugins/javascript/presenter/bridge.rs
@@ -0,0 +1,214 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use futures_util::future::BoxFuture;
+use maka_js_runtime::plugin::{Bridge, Error as VmError};
+use maka_plugins::{
+ remote::{Caller, Error, Method},
+ terminal_ui::view::{Reply, Request},
+};
+use serde::Deserialize;
+use serde_json::Value;
+use std::{
+ collections::HashMap,
+ sync::{Arc, Mutex},
+};
+use tokio_util::task::TaskTracker;
+
+pub(super) struct PageBridge {
+ backend: Arc<dyn Method>,
+ calls: Mutex<HashMap<String, Entry>>,
+ scheduler: tokio::runtime::Handle,
+ tasks: TaskTracker,
+}
+struct Entry {
+ caller: Caller,
+ input: Option<Value>,
+ outcome: Arc<Mutex<Option<Value>>>,
+ read: bool,
+ recover: bool,
+}
+pub(super) struct Guard {
+ bridge: Arc<PageBridge>,
+ pub id: String,
+ outcome: Arc<Mutex<Option<Value>>>,
+ read: bool,
+}
+impl Guard {
+ pub fn outcome(&self) -> Option<Value> {
+ self.outcome.lock().unwrap().clone()
+ }
+ pub fn resolve(&self, result: Result<Value, Error>) -> Result<Value, Error> {
+ if let Some(outcome) = self.outcome() {
+ return Ok(outcome);
+ }
+ let value = result?;
+ if !self.read {
+ return Err(Error::Provider(
+ "terminal backend returned no authoritative outcome".into(),
+ ));
+ }
+ let reply: Reply = serde_json::from_value(value.clone()).map_err(invalid)?;
+ reply.validate().map_err(invalid)?;
+ if matches!(reply, Reply::Applied { .. } | Reply::Unrecorded) {
+ return Err(invalid("a terminal Read cannot produce a mutation outcome"));
+ }
+ Ok(value)
+ }
+}
+impl Drop for Guard {
+ fn drop(&mut self) {
+ self.bridge.calls.lock().unwrap().remove(&self.id);
+ }
+}
+impl PageBridge {
+ pub fn new(backend: Arc<dyn Method>) -> Self {
+ Self {
+ backend,
+ calls: Mutex::default(),
+ scheduler: tokio::runtime::Handle::current(),
+ tasks: TaskTracker::new(),
+ }
+ }
+ pub fn enter(self: &Arc<Self>, input: &Value, caller: Caller) -> Result<Guard, Error> {
+ let request: Request = serde_json::from_value(input.clone()).map_err(invalid)?;
+ request.validate().map_err(invalid)?;
+ if caller.cancellation.is_cancelled() {
+ return Err(Error::Cancelled);
+ }
+ let mut calls = self.calls.lock().unwrap();
+ if self.tasks.is_closed() || calls.len() >= 16 {
+ return Err(Error::Provider("terminal page is closed or busy".into()));
+ }
+ let id = uuid::Uuid::new_v4().to_string();
+ let read = matches!(request, Request::Read { .. });
+ let outcome: Arc<Mutex<Option<Value>>> = Arc::default();
+ calls.insert(
+ id.clone(),
+ Entry {
+ caller,
+ input: Some(input.clone()),
+ outcome: outcome.clone(),
+ read,
+ recover: matches!(request, Request::Recover { .. }),
+ },
+ );
+ Ok(Guard {
+ bridge: self.clone(),
+ id,
+ outcome,
+ read,
+ })
+ }
+ pub fn cancel(&self) -> Vec<String> {
+ let calls = self.calls.lock().unwrap();
+ self.tasks.close();
+ for entry in calls.values() {
+ entry.caller.cancellation.cancel();
+ }
+ calls.keys().cloned().collect()
+ }
+ pub async fn drained(&self) -> Result<(), Error> {
+ tokio::time::timeout(std::time::Duration::from_secs(5), self.tasks.wait())
+ .await
+ .map_err(|_| Error::CleanupUnconfirmed)
+ }
+}
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Backend {
+ authority: String,
+}
+impl Bridge for PageBridge {
+ fn max_output_bytes(&self, _: &str) -> usize {
+ 64 * 1024
+ }
+ fn call(&self, method: String, input: Value) -> BoxFuture<'static, Result<Value, VmError>> {
+ let result = (|| {
+ if method != "page.backend" {
+ return Err(vm_error("terminal capability is unavailable"));
+ }
+ let Backend { authority } = serde_json::from_value(input).map_err(vm_error)?;
+ let mut calls = self.calls.lock().unwrap();
+ if self.tasks.is_closed() {
+ return Err(vm_error("terminal page is retired"));
+ }
+ let entry = calls
+ .get_mut(&authority)
+ .filter(|entry| !entry.caller.cancellation.is_cancelled())
+ .ok_or_else(|| vm_error("terminal call is retired"))?;
+ let input = entry
+ .input
+ .take()
+ .ok_or_else(|| vm_error("terminal backend ticket was already consumed"))?;
+ let caller = entry.caller.clone();
+ let outcome = entry.outcome.clone();
+ let (read, recover) = (entry.read, entry.recover);
+ let mut ticket = caller.resources.reserve().map_err(vm_error)?;
+ ticket.start();
+ let backend = self.backend.clone();
+ // The original caller, JSON and resource ticket remain Host-owned
+ // even after UI teardown drops its promise or attempts to replace it.
+ let task = self.tasks.spawn_on(
+ async move {
+ let result = backend.call(input, caller).await.and_then(|value| {
+ maka_plugins::remote::validate_payload(&value)?;
+ if !read {
+ let reply: Reply =
+ serde_json::from_value(value.clone()).map_err(invalid)?;
+ reply.validate().map_err(invalid)?;
+ if recover
+ && !matches!(
+ reply,
+ Reply::Applied { .. }
+ | Reply::Unrecorded
+ | Reply::Conflict
+ | Reply::Rejected { .. }
+ )
+ {
+ return Err(invalid("invalid terminal recovery outcome"));
+ }
+ *outcome.lock().unwrap() = Some(value.clone());
+ }
+ Ok(value)
+ });
+ ticket.complete(match &result {
+ Err(Error::CleanupUnconfirmed) => {
+ Err("terminal backend cleanup is unconfirmed".into())
+ }
+ _ => Ok(()),
+ });
+ result.map_err(vm_error)
+ },
+ &self.scheduler,
+ );
+ Ok(task)
+ })();
+ Box::pin(async move { result?.await.map_err(vm_error)? })
+ }
+}
+fn invalid(error: impl ToString) -> Error {
+ Error::Invalid(error.to_string())
+}
+fn vm_error(error: impl ToString) -> VmError {
+ VmError(error.to_string())
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/runtime-host/src/plugins/javascript/presenter/bridge/tests.rs b/crates/runtime-host/src/plugins/javascript/presenter/bridge/tests.rs
new file mode 100644
index 0000000000..e7e030ebd5
--- /dev/null
+++ b/crates/runtime-host/src/plugins/javascript/presenter/bridge/tests.rs
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use crate::plugins::javascript::presenter::tests::caller;
+use serde_json::json;
+
+struct BackendCalls(Mutex<Vec<(Value, Caller)>>);
+impl Method for BackendCalls {
+ fn call(&self, input: Value, caller: Caller) -> BoxFuture<'static, Result<Value, Error>> {
+ self.0.lock().unwrap().push((input.clone(), caller));
+ Box::pin(async move {
+ Ok(match input["kind"].as_str() {
+ Some("read") if input["route"] == "large" => json!("x".repeat(64 * 1024)),
+ Some("read") => json!({"original":input}),
+ _ => json!({"kind":"applied","route":input["route"]}),
+ })
+ })
+ }
+}
+fn backend(bridge: &PageBridge, id: &str) -> BoxFuture<'static, Result<Value, VmError>> {
+ bridge.call("page.backend".into(), json!({"authority":id}))
+}
+#[tokio::test]
+async fn backend_tickets_preserve_exact_request_caller_and_receipt_authority() {
+ let calls = Arc::new(BackendCalls(Mutex::default()));
+ let bridge = Arc::new(PageBridge::new(calls.clone()));
+ assert!(
+ backend(&bridge, "initialization-has-no-ticket")
+ .await
+ .is_err()
+ );
+ let original_caller = caller(uuid::Uuid::new_v4());
+ for input in [
+ json!({"kind":"read","route":{"original":true},"locale":"zh-TW"}),
+ json!({"kind":"submit","route":{"receipt":"original"},"revision":"r1","action":"save","fields":{"secret":"original"},"grant":null,"locale":"zh-CN"}),
+ json!({"kind":"recover","route":{"receipt":"original"},"locale":"en"}),
+ ] {
+ let guard = bridge.enter(&input, original_caller.clone()).unwrap();
+ assert!(
+ bridge
+ .call(
+ "page.backend".into(),
+ json!({"authority":guard.id,"input":{"kind":"submit"}})
+ )
+ .await
+ .is_err()
+ );
+ assert!(
+ bridge
+ .call(
+ "page.read".into(),
+ json!({"authority":guard.id,"key":"secret"})
+ )
+ .await
+ .is_err()
+ );
+ let result = backend(&bridge, &guard.id).await.unwrap();
+ assert!(backend(&bridge, &guard.id).await.is_err());
+ {
+ let values = calls.0.lock().unwrap();
+ let (captured, caller) = values.last().unwrap();
+ assert_eq!(captured, &input);
+ assert_eq!(caller.document_id, original_caller.document_id);
+ assert_eq!(caller.connection_id, original_caller.connection_id);
+ assert_eq!(
+ caller.client_instance_id,
+ original_caller.client_instance_id
+ );
+ assert!(Arc::ptr_eq(&caller.views, &original_caller.views));
+ assert!(Arc::ptr_eq(&caller.resources, &original_caller.resources));
+ }
+ if input["kind"] != "read" {
+ assert_eq!(
+ guard
+ .resolve(Ok(json!({"kind":"applied","route":"forged"})))
+ .unwrap(),
+ result
+ );
+ assert_eq!(guard.resolve(Err(Error::Cancelled)).unwrap(), result);
+ }
+ let id = guard.id.clone();
+ drop(guard);
+ assert!(backend(&bridge, &id).await.is_err());
+ }
+ assert_eq!(calls.0.lock().unwrap().len(), 3);
+ let input = json!({"kind":"submit","route":null,"revision":"r1","action":"save","fields":{},"locale":"en"});
+ let guard = bridge.enter(&input, caller(uuid::Uuid::new_v4())).unwrap();
+ assert!(
+ guard
+ .resolve(Ok(json!({"kind":"applied","route":null})))
+ .is_err()
+ );
+ bridge.cancel();
+ assert!(backend(&bridge, &guard.id).await.is_err());
+ bridge.drained().await.unwrap();
+}
+#[tokio::test]
+async fn backend_read_model_is_bounded_and_cancelled_tickets_never_dispatch() {
+ let calls = Arc::new(BackendCalls(Mutex::default()));
+ let bridge = Arc::new(PageBridge::new(calls.clone()));
+ let input = json!({"kind":"read","route":"large","locale":"en"});
+ let cx = caller(uuid::Uuid::new_v4());
+ let guard = bridge.enter(&input, cx.clone()).unwrap();
+ assert!(backend(&bridge, &guard.id).await.is_err());
+ assert!(guard.outcome().is_none());
+ let next = bridge.enter(&input, cx.clone()).unwrap();
+ cx.cancellation.cancel();
+ assert!(backend(&bridge, &next.id).await.is_err());
+ assert_eq!(calls.0.lock().unwrap().len(), 1);
+ bridge.cancel();
+ bridge.drained().await.unwrap();
+}
diff --git a/crates/runtime-host/src/plugins/javascript/presenter/tests.rs b/crates/runtime-host/src/plugins/javascript/presenter/tests.rs
new file mode 100644
index 0000000000..a53be0378a
--- /dev/null
+++ b/crates/runtime-host/src/plugins/javascript/presenter/tests.rs
@@ -0,0 +1,203 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use maka_js_runtime::plugin::{Bridge, Error as VmError};
+use maka_plugins::{remote, terminal_ui::presenter::Factory as _};
+use serde_json::json;
+use std::collections::BTreeMap;
+use tokio::sync::Semaphore;
+
+const SOURCE: &str = r#"
+let factories = 0;
+export default function create() {
+ factories++;
+ let reads = 0;
+ return {
+ async read(route, cx) {
+ await cx.backend();
+ if (route === 'write') await cx.backend();
+ if (route === 'loop') {
+ while (true) {}
+ }
+ return {title:'Page',revision:String(factories),fields:[],actions:[],
+ root:{kind:'text',key:'value',spans:[{text:String(++reads),tone:'normal'}]}};
+ },
+ submit(_input, cx) { return cx.backend(); }
+ };
+}
+"#;
+const BUSINESS: &str = r#"
+let activations = 0, ticks = 0;
+export default async function activate(ctx) {
+ activations++;
+ await ctx.remote.method('stats', () => ({activations,ticks}));
+ ctx.run(async () => { while (!ctx.signal.aborted) { await ctx.sleep(1); ticks++; } });
+}
+"#;
+struct Clock {
+ entered: Arc<Semaphore>,
+ release: Arc<Semaphore>,
+}
+impl Bridge for Clock {
+ fn call(&self, method: String, _: Value) -> BoxFuture<'static, Result<Value, VmError>> {
+ assert_eq!(method, "clock.sleep");
+ let entered = self.entered.clone();
+ let release = self.release.clone();
+ Box::pin(async move {
+ entered.add_permits(1);
+ release.acquire().await.unwrap().forget();
+ Ok(json!({"ok":true,"value":null}))
+ })
+ }
+}
+struct Backend(std::sync::Mutex<Vec<(Value, uuid::Uuid)>>, Arc<Semaphore>);
+impl Method for Backend {
+ fn call(&self, input: Value, caller: Caller) -> BoxFuture<'static, Result<Value, Error>> {
+ self.0
+ .lock()
+ .unwrap()
+ .push((input.clone(), caller.document_id));
+ if input["route"] == "loop" {
+ self.1.add_permits(1);
+ }
+ Box::pin(async move {
+ Ok(if input["kind"] == "read" {
+ json!({"model":true})
+ } else {
+ json!({"kind":"applied","route":null})
+ })
+ })
+ }
+}
+struct Views;
+impl remote::Views for Views {
+ fn authorize(
+ &self,
+ _: maka_plugins::authorization::Request,
+ ) -> BoxFuture<'_, Result<maka_plugins::call::Owned, Error>> {
+ Box::pin(async { Err(Error::Retired) })
+ }
+ fn session(&self) -> BoxFuture<'_, Result<remote::SessionView, Error>> {
+ Box::pin(async { Err(Error::Retired) })
+ }
+ fn workspace(
+ &self,
+ _: remote::WorkspaceViewInput,
+ ) -> BoxFuture<'_, Result<remote::SessionView, Error>> {
+ Box::pin(async { Err(Error::Retired) })
+ }
+ fn query_database(
+ &self,
+ _: maka_plugins::filesystem::database::Read,
+ ) -> BoxFuture<
+ '_,
+ Result<
+ Vec<maka_plugins::filesystem::database::Table>,
+ maka_plugins::filesystem::database::Error,
+ >,
+ > {
+ panic!("fixture has no database authority")
+ }
+}
+pub(super) fn caller(document_id: uuid::Uuid) -> Caller {
+ Caller {
+ connection_id: uuid::Uuid::new_v4(),
+ client_instance_id: "presenter-test".into(),
+ document_id,
+ session_id: None,
+ access: remote::Access::Granted,
+ views: Arc::new(Views),
+ resources: Arc::default(),
+ cancellation: CancellationToken::new(),
+ }
+}
+fn read(route: Value) -> Value {
+ json!({"kind":"read","route":route,"locale":"en"})
+}
+async fn step(clock: &Clock) {
+ clock.release.add_permits(1);
+ clock.entered.acquire().await.unwrap().forget();
+}
+async fn stats(business: &Module) -> Value {
+ business
+ .call(
+ vec!["invoke".into()],
+ vec![json!(1), Value::Null, json!({}), json!("stats")],
+ )
+ .await
+ .unwrap()["value"]
+ .clone()
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
+async fn page_vm_failure_preserves_sibling_factory_and_business_job() {
+ tokio::time::timeout(Duration::from_secs(10), async {
+ let limits = Limits { synchronous_slice: Duration::from_millis(200), ..Limits::default() };
+ let clock = Arc::new(Clock { entered: Arc::new(Semaphore::new(0)), release: Arc::new(Semaphore::new(0)) });
+ let business_vm = Vm::new(limits.clone()).unwrap();
+ let business = business_vm.load_plugin("business.mjs".into(), BUSINESS.into(), clock.clone()).unwrap();
+ business.call(vec!["activate".into()], vec![json!({}), Value::Null]).await.unwrap();
+ let running = business.clone();
+ let job = tokio::spawn(async move { running.call(vec!["effective".into()], vec![]).await });
+ clock.entered.acquire().await.unwrap().forget();
+ let entered = Arc::new(Semaphore::new(0));
+ let backend = Arc::new(Backend(std::sync::Mutex::default(), entered.clone()));
+ let package = Package::new(BTreeMap::from([
+ ("maka.extension.json".into(), serde_json::to_vec(&json!({"schemaVersion":1,"id":"example.page","runtime":{"entry":"host.mjs","sdkVersion":2}})).unwrap()),
+ ("host.mjs".into(), BUSINESS.as_bytes().to_vec()),
+ ("page.mjs".into(), SOURCE.as_bytes().to_vec()),
+ ])).unwrap();
+ let capacity = Arc::new(Capacity::new(limits));
+ let factory = Factory::new(&package, "page.mjs", capacity.clone(), backend.clone(), CancellationToken::new(), vec![]).unwrap();
+ assert!(Factory::new(&package, "../page.mjs", capacity.clone(), backend.clone(), CancellationToken::new(), vec![]).is_err());
+ let page_a = factory.open(CancellationToken::new()).unwrap();
+ let page_b = factory.open(CancellationToken::new()).unwrap();
+ let third = factory.open(CancellationToken::new()).unwrap();
+ let fourth = factory.open(CancellationToken::new()).unwrap();
+ assert!(factory.open(CancellationToken::new()).is_err());
+ third.close().await.unwrap();
+ fourth.close().await.unwrap();
+ let a = caller(uuid::Uuid::new_v4());
+ let b = caller(uuid::Uuid::new_v4());
+ assert_eq!(page_b.call(read(Value::Null), b.clone()).await.unwrap()["view"]["revision"], "1");
+ let fault = tokio::spawn(page_a.call(read(json!("loop")), a));
+ entered.acquire().await.unwrap().forget();
+ // These complete while A is executing its synchronous loop.
+ step(&clock).await;
+ let sibling = page_b.call(read(Value::Null), b.clone()).await.unwrap();
+ assert_eq!(sibling["view"]["revision"], "1");
+ assert_eq!(sibling["view"]["root"]["spans"][0]["text"], "2");
+ assert!(!fault.is_finished(), "sibling must complete before A's watchdog failure");
+ assert!(matches!(fault.await.unwrap(), Err(Error::Provider(_) | Error::Retired | Error::Cancelled)));
+ page_a.close().await.unwrap();
+ step(&clock).await;
+ assert_eq!(stats(&business).await, json!({"activations":1,"ticks":2}));
+ let submission = json!({"kind":"submit","route":null,"revision":"r1","action":"save","fields":{},"locale":"en"});
+ assert_eq!(page_b.call(submission.clone(), b.clone()).await.unwrap()["kind"], "applied");
+ assert!(backend.0.lock().unwrap().contains(&(submission, b.document_id)));
+ let calls = backend.0.lock().unwrap().len();
+ assert!(page_b.call(read(json!("write")), b).await.is_err());
+ assert_eq!(backend.0.lock().unwrap().len(), calls + 1);
+ page_b.close().await.unwrap();
+ assert_eq!(capacity.slots.available_permits(), 4);
+ business_vm.shutdown().await;
+ assert!(job.await.unwrap().is_err());
+ }).await.unwrap();
+}
diff --git a/crates/runtime-host/src/plugins/javascript/registration.rs b/crates/runtime-host/src/plugins/javascript/registration.rs
index a6a516bfd8..01f7ec501e 100644
--- a/crates/runtime-host/src/plugins/javascript/registration.rs
+++ b/crates/runtime-host/src/plugins/javascript/registration.rs
@@ -17,6 +17,8 @@
* under the License.
*/
+mod terminal;
+
use super::callbacks;
use maka_js_runtime::plugin::Module;
use maka_plugins::{contributions::Staged, prompt};
@@ -61,6 +63,8 @@ pub(super) enum Registration {
access: maka_plugins::remote::Access,
#[serde(default, rename = "terminalView")]
terminal_view: Option<maka_plugins::terminal_ui::Descriptor>,
+ #[serde(default)]
+ observation: Option<terminal::Observation>,
},
RemoteStream {
name: String,
@@ -69,6 +73,19 @@ pub(super) enum Registration {
access: maka_plugins::remote::Access,
#[serde(default, rename = "terminalView")]
terminal_view: Option<maka_plugins::terminal_ui::Descriptor>,
+ #[serde(default)]
+ observation: Option<terminal::Observation>,
+ },
+ TerminalApp {
+ name: String,
+ callback: u32,
+ entry: String,
+ #[serde(default)]
+ resources: Vec<maka_plugins::terminal_ui::transcript::Resource>,
+ #[serde(default)]
+ access: maka_plugins::remote::Access,
+ #[serde(rename = "terminalView")]
+ terminal_view: maka_plugins::terminal_ui::Descriptor,
},
#[serde(rename_all = "camelCase")]
Executor {
@@ -189,7 +206,7 @@ pub(super) fn stage(
)
}
pub(super) fn stage_entries(
- registrations: Vec<Registration>,
+ mut registrations: Vec<Registration>,
module: &Module,
outputs: &Arc<super::executor::Outputs>,
model_calls: &Arc<super::model::Calls>,
@@ -201,8 +218,15 @@ pub(super) fn stage_entries(
return Err("plugin contribution limit exceeded".into());
}
let mut staged = Staged::default();
+ terminal::stage(
+ &mut registrations,
+ module,
+ calls,
+ source,
+ lifecycle,
+ &mut staged,
+ )?;
for registration in registrations {
- let remote_stream = matches!(®istration, Registration::RemoteStream { .. });
match registration {
Registration::Background { name, callback } => {
validate_callback(callback)?;
@@ -257,44 +281,10 @@ pub(super) fn stage_entries(
)
.map_err(super::message)?;
}
- Registration::RemoteMethod {
- name,
- callback,
- access,
- terminal_view,
- }
- | Registration::RemoteStream {
- name,
- callback,
- access,
- terminal_view,
- } => {
- validate_callback(callback)?;
- let handler = Arc::new(super::remote::Remote(Arc::new(callbacks::Callback {
- module: module.clone(),
- id: callback,
- calls: calls.clone(),
- })));
- let handler = if remote_stream {
- maka_plugins::remote::Handler::Stream(handler)
- } else {
- maka_plugins::remote::Handler::Method(handler)
- };
- let mut endpoint =
- maka_plugins::remote::Endpoint::new(source.content_digest.clone(), handler);
- endpoint.access = access;
- if let Some(descriptor) = terminal_view {
- endpoint = endpoint
- .with_terminal_view(descriptor)
- .map_err(super::message)?;
- }
- staged
- .insert(
- maka_plugins::remote::key(&source.package_id, &name)
- .map_err(super::message)?,
- endpoint,
- )
- .map_err(super::message)?;
+ Registration::RemoteMethod { .. }
+ | Registration::RemoteStream { .. }
+ | Registration::TerminalApp { .. } => {
+ unreachable!("remote entries are staged together")
}
Registration::ModelProvider {
name,
@@ -467,6 +457,7 @@ pub(super) enum Kind {
InputPreparation,
RemoteMethod,
RemoteStream,
+ TerminalApp,
Executor,
Tool,
Section,
@@ -490,7 +481,7 @@ pub(super) fn withdraw(
Kind::InputPreparation => {
publisher.withdraw_many::<maka_plugins::input::InputPreparation>(names)
}
- Kind::RemoteMethod | Kind::RemoteStream => {
+ Kind::RemoteMethod | Kind::RemoteStream | Kind::TerminalApp => {
let package = context.identity()?.package_id;
publisher.withdraw_many::<maka_plugins::remote::Endpoint>(
&names
diff --git a/crates/runtime-host/src/plugins/javascript/registration/terminal.rs b/crates/runtime-host/src/plugins/javascript/registration/terminal.rs
new file mode 100644
index 0000000000..58c8c4d2c1
--- /dev/null
+++ b/crates/runtime-host/src/plugins/javascript/registration/terminal.rs
@@ -0,0 +1,251 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{Registration, validate_callback};
+use crate::plugins::javascript::{callbacks, invocation, presenter, remote};
+use maka_js_runtime::plugin::Module;
+use maka_plugins::{
+ contributions::Staged,
+ fiber::Context,
+ remote::{Endpoint, Handler},
+ terminal_ui::{
+ presenter::{Observation as Member, ObservationRole},
+ transcript::Resource,
+ },
+};
+use serde::Deserialize;
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ sync::Arc,
+};
+
+#[derive(Deserialize)]
+#[serde(tag = "role", rename_all = "snake_case", deny_unknown_fields)]
+pub(in crate::plugins::javascript) enum Observation {
+ TranscriptRead { resource: Resource },
+ TranscriptStream { resource: Resource },
+}
+impl Observation {
+ fn validate(self, name: &str, stream: bool) -> Result<ObservationRole, String> {
+ let (resource, read) = match self {
+ Self::TranscriptRead { resource } => (resource, true),
+ Self::TranscriptStream { resource } => (resource, false),
+ };
+ resource.validate().map_err(super::super::message)?;
+ if read == stream
+ || name
+ != if read {
+ &resource.read
+ } else {
+ &resource.stream
+ }
+ {
+ return Err(
+ "Transcript contribution role or method does not match its resource".into(),
+ );
+ }
+ Ok(if read {
+ ObservationRole::TranscriptRead(resource)
+ } else {
+ ObservationRole::TranscriptStream(resource)
+ })
+ }
+}
+
+pub(super) fn stage(
+ registrations: &mut Vec<Registration>,
+ module: &Module,
+ calls: &Arc<invocation::Calls>,
+ source: &remote::Source,
+ lifecycle: &Context,
+ staged: &mut Staged,
+) -> Result<(), String> {
+ let identity = lifecycle.identity().map_err(super::super::message)?;
+ let mut endpoints = BTreeMap::new();
+ let mut apps = Vec::new();
+ for registration in std::mem::take(registrations) {
+ let stream = matches!(®istration, Registration::RemoteStream { .. });
+ match registration {
+ Registration::RemoteMethod {
+ name,
+ callback,
+ access,
+ terminal_view,
+ observation,
+ }
+ | Registration::RemoteStream {
+ name,
+ callback,
+ access,
+ terminal_view,
+ observation,
+ } => {
+ validate_callback(callback)?;
+ let handler = Arc::new(remote::Remote(Arc::new(callbacks::Callback {
+ module: module.clone(),
+ id: callback,
+ calls: calls.clone(),
+ })));
+ let handler = if stream {
+ Handler::Stream(handler)
+ } else {
+ Handler::Method(handler)
+ };
+ let mut endpoint = Endpoint::new(source.package.digest().into(), handler);
+ endpoint.access = access;
+ endpoint.observation = observation
+ .map(|value| value.validate(&name, stream))
+ .transpose()?;
+ if terminal_view.is_some() {
+ return Err(
+ "JavaScript terminal metadata requires ctx.tui.app with an isolated entry"
+ .into(),
+ );
+ }
+ if endpoints.insert(name, endpoint).is_some() {
+ return Err("duplicate Remote contribution".into());
+ }
+ }
+ app @ Registration::TerminalApp { .. } => apps.push(app),
+ other => registrations.push(other),
+ }
+ }
+ let app_names: BTreeSet<_> = apps
+ .iter()
+ .map(|app| match app {
+ Registration::TerminalApp { name, .. } => name.clone(),
+ _ => unreachable!(),
+ })
+ .collect();
+ if app_names.len() != apps.len() || app_names.iter().any(|name| endpoints.contains_key(name)) {
+ return Err("duplicate terminal app contribution".into());
+ }
+ // Capture from the same publication first; existing contributions must still
+ // be effective and owned by this exact activation. No deferred name lookup.
+ let current = source.catalog.snapshot::<Endpoint>(&identity.scope);
+ for app in apps {
+ let Registration::TerminalApp {
+ name,
+ callback,
+ entry,
+ resources,
+ access,
+ terminal_view,
+ } = app
+ else {
+ unreachable!()
+ };
+ validate_callback(callback)?;
+ terminal_view.validate().map_err(super::super::message)?;
+ let mut members = Vec::new();
+ let mut methods = BTreeSet::new();
+ let mut ids = BTreeSet::new();
+ let mut capture = |method: &str, role: ObservationRole| -> Result<(), String> {
+ if !methods.insert(method.to_owned()) || app_names.contains(method) {
+ return Err("terminal observation methods must have one source role".into());
+ }
+ let key = maka_plugins::remote::key(&identity.package_id, method)
+ .map_err(super::super::message)?;
+ let endpoint = match endpoints.get(method) {
+ Some(endpoint) => endpoint,
+ None => {
+ let item = current
+ .entries
+ .get(&key)
+ .filter(|item| {
+ item.is_effective()
+ && item.owner.identity().is_ok_and(|owner| owner == identity)
+ })
+ .ok_or("terminal observation source is not effective in this activation")?;
+ &item.value
+ }
+ };
+ let expected_stream = !matches!(role, ObservationRole::TranscriptRead(_));
+ if matches!(endpoint.handler, Handler::Stream(_)) != expected_stream
+ || endpoint.terminal_view().is_some()
+ || match &role {
+ ObservationRole::ChangesStream => endpoint.observation.is_some(),
+ _ => endpoint.observation.as_ref() != Some(&role),
+ }
+ {
+ return Err("terminal observation source kind or resource does not match".into());
+ }
+ members.push(Member {
+ method: method.into(),
+ target: endpoint.target(&identity),
+ role,
+ });
+ Ok(())
+ };
+ if let Some(changes) = &terminal_view.changes {
+ capture(changes, ObservationRole::ChangesStream)?;
+ }
+ for resource in resources {
+ resource.validate().map_err(super::super::message)?;
+ if !ids.insert(resource.id.clone()) {
+ return Err("duplicate terminal resource identity".into());
+ }
+ capture(
+ &resource.read,
+ ObservationRole::TranscriptRead(resource.clone()),
+ )?;
+ capture(
+ &resource.stream,
+ ObservationRole::TranscriptStream(resource.clone()),
+ )?;
+ }
+ let backend = Arc::new(remote::Remote(Arc::new(callbacks::Callback {
+ module: module.clone(),
+ id: callback,
+ calls: calls.clone(),
+ })));
+ let factory = Arc::new(
+ presenter::Factory::new(
+ &source.package,
+ &entry,
+ source.presenters.clone(),
+ backend,
+ lifecycle.stopping().map_err(super::super::message)?,
+ members,
+ )
+ .map_err(super::super::message)?,
+ );
+ let mut endpoint = Endpoint::new(
+ source.package.digest().into(),
+ Handler::Method(Arc::new(presenter::Endpoint(factory))),
+ );
+ endpoint.access = access;
+ endpoints.insert(
+ name,
+ endpoint
+ .with_terminal_view(terminal_view)
+ .map_err(super::super::message)?,
+ );
+ }
+ for (name, endpoint) in endpoints {
+ staged
+ .insert(
+ maka_plugins::remote::key(&identity.package_id, &name)
+ .map_err(super::super::message)?,
+ endpoint,
+ )
+ .map_err(super::super::message)?;
+ }
+ Ok(())
+}
diff --git a/crates/runtime-host/src/plugins/javascript/remote.rs b/crates/runtime-host/src/plugins/javascript/remote.rs
index 46fc25213f..9417a91ce8 100644
--- a/crates/runtime-host/src/plugins/javascript/remote.rs
+++ b/crates/runtime-host/src/plugins/javascript/remote.rs
@@ -26,8 +26,9 @@ use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
pub(super) struct Source {
- pub package_id: String,
- pub content_digest: String,
+ pub package: maka_plugins::package::Package,
+ pub presenters: Arc<super::presenter::Capacity>,
+ pub catalog: maka_plugins::contributions::Catalog,
}
pub(super) struct Remote(pub Arc<Callback>);
impl Method for Remote {
diff --git a/crates/runtime-host/src/server.rs b/crates/runtime-host/src/server.rs
index 86ca0462f1..bb15e6176f 100644
--- a/crates/runtime-host/src/server.rs
+++ b/crates/runtime-host/src/server.rs
@@ -53,6 +53,7 @@ mod resources;
pub(crate) mod retirement;
mod sessions;
pub(crate) use sessions::workspace::resolve_path as resolve_workspace_path;
+mod startup;
mod subscriptions;
mod turns;
pub mod websocket;
diff --git a/crates/runtime-host/src/server/plugin_remote.rs b/crates/runtime-host/src/server/plugin_remote.rs
index 354e07a694..8eec6d656a 100644
--- a/crates/runtime-host/src/server/plugin_remote.rs
+++ b/crates/runtime-host/src/server/plugin_remote.rs
@@ -24,13 +24,12 @@ pub(super) use registry::Registry;
use super::Host;
use futures_util::FutureExt;
-use maka_plugins::remote::{Caller, Error, Handler, validate_payload};
+use maka_plugins::remote::{Caller, Error, Handler};
use maka_protocol::{
OperationError, OperationErrorCode as Code,
plugin::{RemoteKind, RemoteRequest, RemoteResult},
};
-use serde_json::Value;
-use std::{sync::Arc, time::Duration};
+use std::sync::Arc;
use uuid::Uuid;
pub(super) async fn execute(
@@ -124,7 +123,12 @@ pub(super) async fn execute(
},
});
};
- let reservation = host.plugin_remotes.get(connection, document)?.reserve()?;
+ let reservation = host.plugin_remotes.get(connection, document)?.reserve(
+ &binding,
+ &bound.target,
+ stream,
+ &input,
+ )?;
let cancellation = reservation.document.cancellation.child_token();
let resources = Arc::new(maka_plugins::call::Resources::default());
let caller = Caller {
@@ -166,29 +170,68 @@ pub(super) async fn execute(
Ok(RemoteResult::Opened { stream })
}
(Handler::Method(method), RemoteKind::Method) => {
+ let (submitting, settling) = if bound.endpoint.value.terminal_view().is_some() {
+ use maka_plugins::terminal_ui::view::Request;
+ let request: Request = serde_json::from_value(input.clone())
+ .map_err(|error| failure(Error::Invalid(error.to_string())))?;
+ request
+ .validate()
+ .map_err(|error| failure(Error::Invalid(error.to_string())))?;
+ (
+ matches!(request, Request::Submit { .. }),
+ matches!(request, Request::Submit { .. } | Request::Recover { .. }),
+ )
+ } else {
+ (false, false)
+ };
let method = method.clone();
let leases = bound.admit()?;
+ let bound = Arc::new(bound);
+ let method = reservation.document.method(
+ &binding,
+ bound.clone(),
+ method,
+ &host.plugin_tasks,
+ &input,
+ )?;
+ let preserve_receipt = method.isolated() && settling;
let resources = caller.resources.clone();
let cancellation = caller.cancellation.clone();
let (send, receive) = tokio::sync::oneshot::channel();
host.plugin_tasks.spawn(async move {
let _leases = leases;
- let mut result = std::panic::AssertUnwindSafe(call_method(
- &bound, method, input, caller,
+ let mut result = std::panic::AssertUnwindSafe(method.run(
+ &bound,
+ input,
+ caller,
+ preserve_receipt,
))
.catch_unwind()
.await
- .unwrap_or(Err(Error::CleanupUnconfirmed));
+ .unwrap_or_else(|_| {
+ Err(if method.isolated() {
+ Error::Provider("Terminal page panicked".into())
+ } else {
+ Error::CleanupUnconfirmed
+ })
+ });
cancellation.cancel();
- if resources.finish().await.is_err() {
- result = Err(Error::CleanupUnconfirmed);
- }
- if matches!(result, Err(Error::CleanupUnconfirmed)) {
+ let mut cleanup_failed = result.is_err()
+ && method
+ .close_failed_page(&reservation.document)
+ .await
+ .is_err();
+ cleanup_failed |= resources.finish().await.is_err();
+ cleanup_failed |= matches!(result, Err(Error::CleanupUnconfirmed));
+ if cleanup_failed {
reservation.document.cleanup_failed();
bound
.endpoint
.owner
.cleanup_failed("Remote method cleanup is unconfirmed".into());
+ if !(preserve_receipt && result.as_ref().is_ok_and(registry::receipt)) {
+ result = Err(Error::CleanupUnconfirmed);
+ }
}
let _ = send.send(result);
drop(reservation);
@@ -196,8 +239,17 @@ pub(super) async fn execute(
drop(gate);
let value = receive
.await
- .map_err(|_| failure(Error::CleanupUnconfirmed))?
- .map_err(failure)?;
+ .unwrap_or(Err(Error::CleanupUnconfirmed))
+ .map_err(|error| {
+ // An admitted Submit may have committed before its
+ // callback failed. Cleanup fences above are independent
+ // of the missing business result; only recovery can settle it.
+ let mut error = failure(error);
+ if submitting {
+ error.code = Code::OutcomeUnknown;
+ }
+ error
+ })?;
Ok(RemoteResult::Value { value })
}
_ => Err(failure(Error::Invalid(
@@ -208,40 +260,6 @@ pub(super) async fn execute(
}
}
-async fn call_method(
- bound: &crate::plugins::remote::Bound,
- method: Arc<dyn maka_plugins::remote::Method>,
- input: Value,
- caller: Caller,
-) -> Result<Value, Error> {
- let cancellation = caller.cancellation.clone();
- let _cancel_on_exit = cancellation.clone().drop_guard();
- let mut call = method.call(input, caller);
- let result = tokio::select! {
- biased;
- _ = cancellation.cancelled() => Err(Error::Cancelled),
- _ = bound.retired() => Err(Error::Retired),
- _ = tokio::time::sleep(Duration::from_secs(30)) => Err(Error::Cancelled),
- result = &mut call => {
- if matches!(result, Err(Error::CleanupUnconfirmed)) {
- bound.endpoint.owner.cleanup_failed("Remote method cleanup is unconfirmed".into());
- }
- return result.and_then(|value| { validate_payload(&value)?; Ok(value) });
- }
- };
- cancellation.cancel();
- match tokio::time::timeout(Duration::from_secs(5), call).await {
- Ok(result) if !matches!(result, Err(Error::CleanupUnconfirmed)) => {}
- _ => {
- bound
- .endpoint
- .owner
- .cleanup_failed("Remote method ignored cancellation".into());
- return Err(Error::CleanupUnconfirmed);
- }
- };
- result
-}
fn failure(error: Error) -> OperationError {
OperationError {
code: match error {
diff --git a/crates/runtime-host/src/server/plugin_remote/registry.rs b/crates/runtime-host/src/server/plugin_remote/registry.rs
index 3b293a0e5e..5e3fe09e9f 100644
--- a/crates/runtime-host/src/server/plugin_remote/registry.rs
+++ b/crates/runtime-host/src/server/plugin_remote/registry.rs
@@ -17,9 +17,17 @@
* under the License.
*/
+mod binding;
+mod page;
+pub(super) use page::receipt;
+
use super::{failure, worker};
-use maka_plugins::remote::Error;
-use maka_protocol::OperationError;
+use maka_plugins::remote::{Error, Target};
+use maka_protocol::{
+ OperationError,
+ plugin::{RemoteBinding, RemoteKind},
+};
+use serde_json::Value;
use std::{
collections::HashMap,
sync::{Arc, Mutex},
@@ -54,10 +62,14 @@ pub(super) struct Document {
struct DocumentState {
closed: bool,
cleanup_failed: bool,
+ ordinary: bool,
streams: HashMap<Uuid, Arc<worker::Handle>>,
+ presenter: Option<binding::Binding>,
+ reservations: HashMap<Uuid, binding::Identity>,
}
pub(super) struct Reservation {
pub document: Arc<Document>,
+ id: Uuid,
_permits: Vec<OwnedSemaphorePermit>,
_task: TaskTrackerToken,
}
@@ -100,7 +112,10 @@ impl Registry {
state: Mutex::new(DocumentState {
closed: false,
cleanup_failed: false,
+ ordinary: false,
streams: HashMap::new(),
+ presenter: None,
+ reservations: HashMap::new(),
}),
capacity: [
Arc::new(Semaphore::new(32)),
@@ -167,19 +182,33 @@ impl Drop for Connection {
}
}
impl Document {
- pub fn reserve(self: &Arc<Self>) -> Result<Reservation, OperationError> {
- let state = self.state.lock().unwrap();
+ pub fn reserve(
+ self: &Arc<Self>,
+ binding: &RemoteBinding,
+ target: &Target,
+ kind: RemoteKind,
+ input: &Value,
+ ) -> Result<Reservation, OperationError> {
+ let mut state = self.state.lock().unwrap();
if state.closed {
return Err(failure(Error::Cancelled));
}
+ if let Some(presenter) = &state.presenter {
+ presenter.check(binding, target, kind, input)?;
+ }
let permits = self
.capacity
.iter()
.map(|capacity| capacity.clone().try_acquire_owned())
.collect::<Result<Vec<_>, _>>()
.map_err(|_| failure(Error::Invalid("Remote capacity exhausted".into())))?;
+ let id = Uuid::new_v4();
+ state
+ .reservations
+ .insert(id, binding::Identity::new(binding, target, kind));
Ok(Reservation {
document: self.clone(),
+ id,
_permits: permits,
_task: self.tasks.token(),
})
@@ -189,6 +218,9 @@ impl Document {
if state.closed {
return Err(failure(Error::Cancelled));
}
+ if state.presenter.is_none() {
+ state.ordinary = true;
+ }
state.streams.insert(id, stream);
Ok(())
}
@@ -216,13 +248,30 @@ impl Document {
pub fn close(&self) {
let mut state = self.state.lock().unwrap();
state.closed = true;
+ let presenter = state.presenter.clone();
self.cancellation.cancel();
self.tasks.close();
+ drop(state);
+ if let Some(presenter) = presenter {
+ presenter.cancel();
+ }
}
pub async fn drained(&self) -> Result<(), OperationError> {
- tokio::time::timeout(std::time::Duration::from_secs(6), self.tasks.wait())
+ // A cancelled page settles for at most six seconds; its independent
+ // invocation resources then retain their existing five-second budget.
+ tokio::time::timeout(std::time::Duration::from_secs(12), self.tasks.wait())
.await
.map_err(|_| failure(Error::CleanupUnconfirmed))?;
self.check_cleanup()
}
}
+impl Drop for Reservation {
+ fn drop(&mut self) {
+ self.document
+ .state
+ .lock()
+ .unwrap()
+ .reservations
+ .remove(&self.id);
+ }
+}
diff --git a/crates/runtime-host/src/server/plugin_remote/registry/binding.rs b/crates/runtime-host/src/server/plugin_remote/registry/binding.rs
new file mode 100644
index 0000000000..448e989dc7
--- /dev/null
+++ b/crates/runtime-host/src/server/plugin_remote/registry/binding.rs
@@ -0,0 +1,274 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
... 18776 lines suppressed ...
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org