diff --git a/CHANGELOG.md b/CHANGELOG.md index 22bf505d04..21aea48b15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,41 @@ ## Unreleased -## 0.2.0 - Unreleased +## 0.2.1 - Unreleased + +### Added + +- WorkHub can inspect existing conversations, receive delegated task results automatically, and route remote channel conversations through the shared coordinator (#5886, #5595, #6051). Jev-assisted routing is opt-in (#5562). +- Host storage usage and per-task sizes, archived-task cleanup by age and project, and opt-in archived-task retention (#5832, #5896, #5902, #5961). +- Agent Graph output previews, session visit navigation with horizontal swipes, clickable terminal links, and a selectable Git comparison branch that persists per Session (#4751, #5688, #5033, #5120). +- Resume interrupted or manually stopped turns from the Desktop composer, and opt-in next-prompt suggestions (#5206, #5904, #5629). +- Connect the user's signed-in Chrome through opencli-mcp, and check local preview prerequisites before starting a preview (#5638, #5254). +- Antigravity ACP plugin execution and restored sessions after restart; ACP operations for Goal, Plan, Artifact and Memory (#5224, #5670, #5734, #5685). +- Filter usage by model call kind, replay DeepSeek hosted web-search responses, and include selected text in Computer Use snapshot observations (#5709, #5824, #5050). + +### Fixed + +- Enforce configured sender lists consistently across messaging platforms and preserve stored credentials when settings round-trip masked values (#6074). +- Keep the composer context gauge from showing stale pre-compaction usage. The gauge returns to its localized Usage label until the provider measures a request after the compaction boundary (#5548). +- Preserve interrupted tool settlement, session recovery and provider context handling across retries and restarts (#5610, #5621, #5587). +- Report browser toolbar action failures and distinguish empty Git comparisons from review errors (#5955, #5967). + +### Changed + +- Custom connections now use one provider type with a default API protocol and per-model overrides. Legacy connections migrate while retaining identifiers, credentials and model overrides (#5684). +- TUI queue and retract shortcuts use Tab and Shift+Left in place of Alt chords (#5910). +- Reduced idle and streaming renderer work, paused transcript reads while the shell is covered, and bounded Host framing and storage queries (#5608, #6014, #5356, #5514). +- Reclaim unused context-offload SQLite pages during Host maintenance; new runtime databases support incremental vacuum (#5855, #6044). +- Removed the provider-dropping context note because differing upstream token counts could trigger it without evidence of dropped history. Historical sessions containing it still open (#5672). + +### Upgrade notes + +- Update Desktop, CLI and remote Runtime Hosts together. The Runtime Host compatibility epoch is 217 (176 in the 0.2.0 source release); mismatched peers cannot connect. +- Back up configuration and workspace data before upgrading if rollback is needed. Custom connection catalogs migrate to schema v3, and new session records include fields older builds cannot decode; 0.2.0 cannot safely reuse data written by 0.2.1. +- Review every messaging platform's sender list before enabling its channel. A nonempty list now admits only its listed platform user IDs, including WorkHub routing; an empty list retains unrestricted sender access (#6074). +- Archived-task retention remains opt-in. Enabling it allows permanent deletion of eligible archived tasks; leave it disabled to retain them. + +## 0.2.0 - 2026-10-08 ### Added @@ -32,23 +66,11 @@ offer an action to open the most recently imported task or create another independent import. An uncertain result remains visible as a warning while still allowing a user-initiated import again in both Desktop and TUI. -- The Git changes panel can pick which branch it compares against — searchable, - per Session — instead of only the branch the backend resolves. The choice - persists, and falls back to the resolved branch when the pinned one disappears. - Added `/transcript` to browse long TUI sessions without depending on terminal scrollback, with line, page, and first/last navigation. ### Fixed -- The composer's context gauge now falls back to its localized `Usage` label - instead of holding the pre-compaction figure after a compaction: no provider - has tokenized the replacement prompt yet, so the last real count is stale. - A `context_compaction_applied` boundary is persisted and forwarded over the - session subscription the moment a compaction lands, so a mid-turn fold - clears the figure while that turn is still running — the `context_compacted` - note stays the settlement-time display row — and only a measurement that - completed after the boundary restores it. A failed-open compaction is not a - boundary: that request went out with its full raw history. - Fixed a renderer crash dialog reporting React error #185 ("Maximum update depth exceeded") coming from the composer's prompt-history inline completion (#4117): the offer engine the 0.1.11 composer fed could flip-flop its announcement state on @@ -75,8 +97,8 @@ - Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Before opening such a workspace with this build, finish or export the Tasks you still need, or copy the workspace's `runtime.sqlite` aside — the migration removes the only live copy, so afterwards recovery requires a backup made in advance. - A compaction rejected as too large for the summarizer's own window now retreats to the span the last accepted request's input covered, instead of halving the covered range. That span is the newest reply this route produced, found through the run headers, so it was accepted by this model on this connection and is provably within capacity; halving can overshoot (discarding verbatim history for nothing) or undershoot (paying another round trip), and a span another route accepted proves nothing at all. One retreat, then the fold fails open and the provider decides. - `token_usage` anchors now record the model and connection that produced them. A token count is a number in one model's tokenizer against one connection; carrying the route on the record lets any reader apply the rule the runtime already enforces, instead of pairing one model's usage with another model's window. The record decodes against a closed allowlist, so sessions written with these keys do not open in earlier releases, and the Runtime Host compatibility epoch moves to 107. -- Removed the provider-dropping note that 0.2.0 pre-releases showed when a provider reported fewer input tokens than the previous request. A relay that moves a request to another upstream reports a different count for the same history, so the note fired when nothing had been dropped, and it offered nothing the user could act on. Sessions that already carry the note still open; the note is no longer shown. -- Let the provider decide whether a request fits. Proactive compaction now uses only a user-declared Maka window and the previous accepted request's provider-reported `inputTokens + outputTokens`; no declaration means no proactive capacity threshold. `/models` and generated model metadata are display hints, not limits. `token_usage` records persist the last-request anchor under `lastRequestAnchor`; its new `{ inputTokens, outputTokens }` shape still decodes the retired `payloadChars` key from older sessions. Requests that are too large are compacted and retried once after a real provider rejection, then reported as a `context_overflow` provider error. Compaction is entered at most once per send, and a request rejected after a fold was actually applied is reported as still too large after compaction. A fold that failed open makes no such claim: that request went out with its full raw history. A reply cut at `finishReason: length` no longer triggers a fold, because the provider running out of window room and the provider's own lower output cap are indistinguishable from outside. Four system notes explain the provider-side cases: a window worth declaring, an exchange past the declared window, a request accepted past the window the model reports (once per crossing, while nothing is declared), and a request still too large after compaction. The reply reserve that arms the proactive threshold is twice the last real reply, bounded at 8,000 tokens, rather than the model's maximum output. **Sessions this build writes do not open in earlier releases:** those decode `token_usage` against a closed allowlist, so the reshaped `lastRequestAnchor` key fails the record and, with it, the Session that contains it; downgrading therefore needs a copy of the workspace's `runtime.sqlite` taken before the upgrade. Nothing produces the `context_budget_exhausted` stop reason any more — a request that really is too large is compacted and retried once, then reported as a `context_overflow` provider error — though sessions that already recorded it still decode and present. The Runtime Host compatibility epoch moves to 106. +- The provider-dropping note now also fires across the send boundary. A provider that truncates to a fixed window reports the same input on every later request while the user keeps adding turns, which a send of one or two steps cannot see from the inside; the first request of a send compares against the persisted anchor instead. Across the boundary the test is equality rather than "did not grow": inside a send Maka knows it only appended, while across it a manual compaction, a smaller tool set or an edited history all shrink the input legitimately, and none of them lands on exactly the same count. The note carries the two counts it compared, and is reported once per backend rather than once per send, because the condition persists once it starts. +- Let the provider decide whether a request fits. Proactive compaction now uses only a user-declared Maka window and the previous accepted request's provider-reported `inputTokens + outputTokens`; no declaration means no proactive capacity threshold. `/models` and generated model metadata are display hints, not limits. `token_usage` records persist the last-request anchor under `lastRequestAnchor`; its new `{ inputTokens, outputTokens }` shape still decodes the retired `payloadChars` key from older sessions. Requests that are too large are compacted and retried once after a real provider rejection, then reported as a `context_overflow` provider error. Compaction is entered at most once per send, and a request rejected after a fold was actually applied is reported as still too large after compaction. A fold that failed open makes no such claim: that request went out with its full raw history. A reply cut at `finishReason: length` no longer triggers a fold, because the provider running out of window room and the provider's own lower output cap are indistinguishable from outside. Five system notes explain the provider-side cases: dropping context, a window worth declaring, an exchange past the declared window, a request accepted past the window the model reports (once per crossing, while nothing is declared), and a request still too large after compaction. The reply reserve that arms the proactive threshold is twice the last real reply, bounded at 8,000 tokens, rather than the model's maximum output. **Sessions this build writes do not open in earlier releases:** those decode `token_usage` against a closed allowlist, so the reshaped `lastRequestAnchor` key fails the record and, with it, the Session that contains it; downgrading therefore needs a copy of the workspace's `runtime.sqlite` taken before the upgrade. Nothing produces the `context_budget_exhausted` stop reason any more — a request that really is too large is compacted and retried once, then reported as a `context_overflow` provider error — though sessions that already recorded it still decode and present. The Runtime Host compatibility epoch moves to 106. - Unified context management under one Runtime-owned policy. `MAKA_CONTEXT_*` environment overrides no longer tune compaction or Tool Result pruning. Archived results are read on demand through bounded `Read` calls with a Maka resource path; `ArchiveRead` is removed. Read requires only `path`, with optional `offset` and `limit`; pass the complete `next` object to continue. Tool Result pruning has one `enabled` switch; the old tuning options are removed. - Moved Read image snapshots into the durable context-offload store with Runtime-owned lifecycle identity, exact branch and revision copying, recovery-safe cleanup, and bounded diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 923ca06f2a..1657256d81 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "@maka/desktop", "productName": "Maka", "description": "A local-first agent workspace built for real work.", - "version": "0.2.0", + "version": "0.2.1", "author": "The Maka Authors", "license": "Apache-2.0", "homepage": "https://github.com/apache/maka", diff --git a/apps/desktop/src/main/workhub-remote-bridge.ts b/apps/desktop/src/main/workhub-remote-bridge.ts index fd500a5612..19b82d4950 100644 --- a/apps/desktop/src/main/workhub-remote-bridge.ts +++ b/apps/desktop/src/main/workhub-remote-bridge.ts @@ -29,7 +29,7 @@ import { RuntimeHostOperationError } from '@maka/runtime-host/client'; import { clientCapabilityEntityId } from '@maka/runtime-host/client-capability-entity-id'; import { SESSION_TRANSCRIPT_PAGE_MAX_BYTES, type InteractionPendingSnapshot, type SessionContinuitySnapshot } from '@maka/runtime-host/protocol'; import type { DesktopRuntimeHostClient, DesktopRuntimeHostSession } from './runtime-host-client.js'; -import { readWorkHubTopicAssociation, workHubAssociationTarget } from '../renderer/application/contracts/workhub-topic-association.js'; +import { readWorkHubTopicAssociation, workHubAssociationTarget } from '../shared/desktop-session-projection.js'; import { formatRemoteChoices, parseRemoteChoices } from './workhub-remote-choices.js'; type Source = Pick & { targetSessionId?: string; associationTargetSessionId?: string | null; associationTurnId?: string }; diff --git a/apps/desktop/src/renderer/application/contracts/workhub-topic-association.ts b/apps/desktop/src/renderer/application/contracts/workhub-topic-association.ts deleted file mode 100644 index 3ad168dd4c..0000000000 --- a/apps/desktop/src/renderer/application/contracts/workhub-topic-association.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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. - */ - -import type { StoredMessage } from '@maka/core/session'; -import { parseDesktopSessionKey } from '../../../shared/runtime-host-identity.js'; - -type AssociatedTopic = { targetSessionId: string; targetSessionKey: string; targetSessionName: string }; -type TopicDecision = AssociatedTopic | { targetSessionId: null }; -const isClearingReason = (reason: unknown) => reason === 'unrelated' || reason === 'overview' || reason === 'ambiguous'; - -/** Topic identity comes only from a successful, validated tasks association receipt. */ -export function readWorkHubTopicAssociation(message: StoredMessage, expectedSessionId: string): AssociatedTopic | undefined; -export function readWorkHubTopicAssociation(message: StoredMessage, expectedSessionId: string | null): TopicDecision | undefined; -export function readWorkHubTopicAssociation(message: StoredMessage, expectedSessionId: string | null): TopicDecision | undefined { - if (message.type !== 'tool_result' || message.isError) return; - let result: unknown; - if (message.content.kind === 'json') result = message.content.value; - else if (message.content.kind === 'text') { - try { result = JSON.parse(message.content.text); } catch { return; } - } - if (result && typeof result === 'object' && 'isError' in result && result.isError) return; - if (result && typeof result === 'object' && 'structuredContent' in result) result = result.structuredContent; - if (!result || typeof result !== 'object' || !('operation' in result) || result.operation !== 'associate' || - !('targetSessionId' in result) || result.targetSessionId !== expectedSessionId) return; - if (expectedSessionId === null) return 'reason' in result && isClearingReason(result.reason) ? { targetSessionId: null } : undefined; - if (!('targetSessionKey' in result) || typeof result.targetSessionKey !== 'string' || - !('targetSessionName' in result) || typeof result.targetSessionName !== 'string') return; - try { if (parseDesktopSessionKey(result.targetSessionKey).sessionId !== expectedSessionId) return; } - catch { return; } - return { targetSessionId: expectedSessionId, targetSessionKey: result.targetSessionKey, targetSessionName: result.targetSessionName }; -} - -/** null is an explicit clearing request; undefined means no valid association request. */ -export function workHubAssociationTarget(message: StoredMessage): string | null | undefined { - if (message.type !== 'tool_call' || message.toolName !== 'mcp__desktop_workhub__tasks') return; - const args = message.args; - const request = args && typeof args === 'object' && 'request' in args ? args.request : undefined; - if (request && typeof request === 'object' && 'operation' in request && request.operation === 'associate' && - 'targetSessionId' in request) { - if (typeof request.targetSessionId === 'string') return request.targetSessionId; - if (request.targetSessionId === null && 'reason' in request && isClearingReason(request.reason)) return null; - } -} diff --git a/apps/desktop/src/renderer/features/workhub/model/linked-work.ts b/apps/desktop/src/renderer/features/workhub/model/linked-work.ts index 5397179bd7..497c1e0aa1 100644 --- a/apps/desktop/src/renderer/features/workhub/model/linked-work.ts +++ b/apps/desktop/src/renderer/features/workhub/model/linked-work.ts @@ -19,7 +19,7 @@ import { desktopSessionKey, parseDesktopSessionKey } from '../../../../shared/runtime-host-identity.js'; -import { readWorkHubTopicAssociation, workHubAssociationTarget } from '../../../application/contracts/workhub-topic-association.js'; +import { readWorkHubTopicAssociation, workHubAssociationTarget } from '../../../../shared/desktop-session-projection.js'; import { workspaceNameFromCwd } from './workspace-name.js'; import type { StoredMessage } from '@maka/core/session'; diff --git a/apps/desktop/src/shared/desktop-session-projection.ts b/apps/desktop/src/shared/desktop-session-projection.ts index d5c15ffcbe..d9acd70985 100644 --- a/apps/desktop/src/shared/desktop-session-projection.ts +++ b/apps/desktop/src/shared/desktop-session-projection.ts @@ -28,7 +28,7 @@ import type { import type { SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; import type { UsageStats } from '@maka/core/settings'; import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind'; -import { desktopSessionKey, type DesktopHostRef } from './runtime-host-identity.js'; +import { desktopSessionKey, parseDesktopSessionKey, type DesktopHostRef } from './runtime-host-identity.js'; export interface DesktopSessionSummary extends SessionSummary { /** Client cache is readable history, not evidence of current Host execution. */ @@ -304,3 +304,41 @@ export function projectDesktopUsageActivity( export function projectDesktopUsageStats(host: DesktopHostRef, stats: UsageStats): UsageStats { return { ...stats, logs: projectDesktopUsageActivity(host, stats.logs) }; } + +type AssociatedTopic = { targetSessionId: string; targetSessionKey: string; targetSessionName: string }; +type TopicDecision = AssociatedTopic | { targetSessionId: null }; +const isClearingReason = (reason: unknown) => reason === 'unrelated' || reason === 'overview' || reason === 'ambiguous'; + +/** Topic identity comes only from a successful, validated tasks association receipt. */ +export function readWorkHubTopicAssociation(message: StoredMessage, expectedSessionId: string): AssociatedTopic | undefined; +export function readWorkHubTopicAssociation(message: StoredMessage, expectedSessionId: string | null): TopicDecision | undefined; +export function readWorkHubTopicAssociation(message: StoredMessage, expectedSessionId: string | null): TopicDecision | undefined { + if (message.type !== 'tool_result' || message.isError) return; + let result: unknown; + if (message.content.kind === 'json') result = message.content.value; + else if (message.content.kind === 'text') { + try { result = JSON.parse(message.content.text); } catch { return; } + } + if (result && typeof result === 'object' && 'isError' in result && result.isError) return; + if (result && typeof result === 'object' && 'structuredContent' in result) result = result.structuredContent; + if (!result || typeof result !== 'object' || !('operation' in result) || result.operation !== 'associate' || + !('targetSessionId' in result) || result.targetSessionId !== expectedSessionId) return; + if (expectedSessionId === null) return 'reason' in result && isClearingReason(result.reason) ? { targetSessionId: null } : undefined; + if (!('targetSessionKey' in result) || typeof result.targetSessionKey !== 'string' || + !('targetSessionName' in result) || typeof result.targetSessionName !== 'string') return; + try { if (parseDesktopSessionKey(result.targetSessionKey).sessionId !== expectedSessionId) return; } + catch { return; } + return { targetSessionId: expectedSessionId, targetSessionKey: result.targetSessionKey, targetSessionName: result.targetSessionName }; +} + +/** null is an explicit clearing request; undefined means no valid association request. */ +export function workHubAssociationTarget(message: StoredMessage): string | null | undefined { + if (message.type !== 'tool_call' || message.toolName !== 'mcp__desktop_workhub__tasks') return; + const args = message.args; + const request = args && typeof args === 'object' && 'request' in args ? args.request : undefined; + if (request && typeof request === 'object' && 'operation' in request && request.operation === 'associate' && + 'targetSessionId' in request) { + if (typeof request.targetSessionId === 'string') return request.targetSessionId; + if (request.targetSessionId === null && 'reason' in request && isClearingReason(request.reason)) return null; + } +} diff --git a/package-lock.json b/package-lock.json index 806a7c3126..fc228a1b70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maka", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maka", - "version": "0.2.0", + "version": "0.2.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -44,7 +44,7 @@ }, "apps/desktop": { "name": "@maka/desktop", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { "@jackwener/opencli": "1.8.8", @@ -17274,7 +17274,7 @@ }, "packages/cli": { "name": "maka-agent", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "1.5.0", diff --git a/package.json b/package.json index 0c20ced3e4..880d79d851 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maka", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "private": true, "engines": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 3278533085..75f4423b61 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "maka-agent", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "private": true, "type": "module", diff --git a/packages/cli/src/__tests__/acp-child-process.test.ts b/packages/cli/src/__tests__/acp-child-process.test.ts index 98166cd93b..59d950ac88 100644 --- a/packages/cli/src/__tests__/acp-child-process.test.ts +++ b/packages/cli/src/__tests__/acp-child-process.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { once } from 'node:events'; -import { realpath, writeFile } from 'node:fs/promises'; +import { readFile, realpath, writeFile } from 'node:fs/promises'; import { createServer, type ServerResponse } from 'node:http'; import { join } from 'node:path'; import { PassThrough } from 'node:stream'; @@ -148,7 +148,13 @@ describe('Maka ACP child process', () => { _meta: { '_maka/goalPlan': { version: 1 } }, }, authMethods: [], - agentInfo: { name: 'maka', title: 'Maka', version: '0.2.0' }, + agentInfo: { + name: 'maka', + title: 'Maka', + version: JSON.parse( + await readFile(new URL('../../package.json', import.meta.url), 'utf8'), + ).version, + }, }); assert.equal( await harness.hasRuntimeHostRootMarker(), diff --git a/scripts/desktop-nightly-release.test.mjs b/scripts/desktop-nightly-release.test.mjs index 6ea2742150..2e4a4d9b40 100644 --- a/scripts/desktop-nightly-release.test.mjs +++ b/scripts/desktop-nightly-release.test.mjs @@ -35,6 +35,10 @@ import { import { writeDesktopReleaseInput } from './desktop-nightly-fixture.mjs'; import { productReleaseArtifactRecords } from './product-release-artifacts.mjs'; +const { version: productVersion } = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8'), +); + async function stageRelease(root, version) { const input = join(root, 'input'); const output = join(root, 'output'); @@ -54,7 +58,7 @@ async function stageRelease(root, version) { test('Nightly publication verifies the exact draft before one Prerelease/non-Latest mutation', async (t) => { const root = await mkdtemp(join(tmpdir(), 'maka-nightly-release-')); t.after(() => rm(root, { recursive: true, force: true })); - const version = '0.2.0-dev.42.20260829'; + const version = `${productVersion}-dev.42.20260829`; const tag = `v${version}`; const sourceCommit = 'a'.repeat(40); const directory = await stageRelease(root, version); @@ -157,7 +161,7 @@ test('Nightly publication verifies the exact draft before one Prerelease/non-Lat test('a missing Nightly release is created only as a draft prerelease with Latest disabled', async (t) => { const root = await mkdtemp(join(tmpdir(), 'maka-nightly-create-')); t.after(() => rm(root, { recursive: true, force: true })); - const version = '0.2.0-dev.42.20260829'; + const version = `${productVersion}-dev.42.20260829`; const tag = `v${version}`; const sourceCommit = 'a'.repeat(40); const directory = await stageRelease(root, version); diff --git a/scripts/desktop-nightly-stage.test.mjs b/scripts/desktop-nightly-stage.test.mjs index c249fa1b53..32356bddf5 100644 --- a/scripts/desktop-nightly-stage.test.mjs +++ b/scripts/desktop-nightly-stage.test.mjs @@ -28,12 +28,16 @@ import { writeDesktopReleaseInput } from './desktop-nightly-fixture.mjs'; import { addDesktopNightlyAttestation, stageDesktopNightly } from './desktop-nightly.mjs'; import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs'; +const { version: productVersion } = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8'), +); + test('staging creates only the exact GitHub Release assets', async (t) => { const root = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-')); t.after(() => rm(root, { recursive: true, force: true })); const input = join(root, 'input'); const output = join(root, 'output'); - const version = '0.2.0-dev.42.20260829'; + const version = `${productVersion}-dev.42.20260829`; await mkdir(input); await writeDesktopReleaseInput(input, version, { nightly: true }); @@ -111,7 +115,7 @@ test('one attestation bundle is staged only as a GitHub Release asset', async (t t.after(() => rm(root, { recursive: true, force: true })); const output = join(root, 'output'); const release = join(output, 'release'); - const version = '0.2.0-dev.42.20260829'; + const version = `${productVersion}-dev.42.20260829`; const bundle = join(root, 'bundle.json'); const bytes = Buffer.from('one offline Sigstore bundle'); await Promise.all([mkdir(release, { recursive: true }), writeFile(bundle, bytes)]); diff --git a/scripts/desktop-nightly.test.mjs b/scripts/desktop-nightly.test.mjs index a413f321b0..050b778d42 100644 --- a/scripts/desktop-nightly.test.mjs +++ b/scripts/desktop-nightly.test.mjs @@ -35,6 +35,7 @@ import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs const run = promisify(execFile); const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const require = createRequire(import.meta.url); +const { version: productVersion } = require('../package.json'); const { GitHubProvider } = require('electron-updater/out/providers/GitHubProvider.js'); const { HttpError } = require('builder-util-runtime'); @@ -65,7 +66,7 @@ releaseDate: '2026-09-18T00:00:00.000Z' } test('a nightly package embeds only the Apache GitHub dev update authority', () => { - const version = '0.2.0-dev.42.20260829'; + const version = `${productVersion}-dev.42.20260829`; const config = resolveDesktopBuilderConfig({ MAKA_DESKTOP_NIGHTLY_VERSION: version, }); @@ -83,7 +84,7 @@ test('a nightly package embeds only the Apache GitHub dev update authority', () }); test('the macOS Nightly wrapper accepts dev update metadata', async () => { - const version = '0.2.0-dev.42.20260829'; + const version = `${productVersion}-dev.42.20260829`; const macosTarget = desktopReleaseTargets(version, { nightly: true }).find( (entry) => entry.name === 'macos-arm64', ); @@ -120,7 +121,7 @@ test('the macOS Nightly wrapper accepts dev update metadata', async () => { }); test('the Windows Nightly wrapper accepts dev update metadata', async () => { - const version = '0.2.0-dev.42.20260829'; + const version = `${productVersion}-dev.42.20260829`; const windowsTarget = desktopReleaseTargets(version, { nightly: true }).find( (entry) => entry.name === 'windows-x64', ); @@ -151,7 +152,7 @@ test('the Windows Nightly wrapper accepts dev update metadata', async () => { }); test('the Linux Nightly wrapper builds the AppImage before the deb and merges one feed', async () => { - const version = '0.2.0-dev.42.20260829'; + const version = `${productVersion}-dev.42.20260829`; const events = []; await packageLinux({ platform: 'linux', @@ -374,7 +375,7 @@ test('formal release checks ignore the ambient Nightly packaging environment', a cwd: repoRoot, env: { ...environment, - MAKA_DESKTOP_NIGHTLY_VERSION: '0.2.0-dev.42.20260829', + MAKA_DESKTOP_NIGHTLY_VERSION: `${productVersion}-dev.42.20260829`, }, }, ); diff --git a/scripts/product-nightly.test.mjs b/scripts/product-nightly.test.mjs index b9bdce0b75..dcdc78630a 100644 --- a/scripts/product-nightly.test.mjs +++ b/scripts/product-nightly.test.mjs @@ -30,6 +30,9 @@ import { assertProductNightlyAdvances } from './release-version.mjs'; const run = promisify(execFile); const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const { version: productVersion } = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8'), +); test('a nightly identity is a dev build of the checked-in product version', () => { assert.deepEqual( @@ -66,7 +69,7 @@ test('the version handoff CLI passes only the exact version to Desktop', async ( const record = join(fixture, 'version.txt'); const output = join(fixture, 'github-output'); const script = join(repoRoot, 'scripts', 'product-nightly.mjs'); - const version = '0.2.0-dev.42.20260829'; + const version = `${productVersion}-dev.42.20260829`; await run(process.execPath, [script, 'write-version', record, version]); await run(process.execPath, [script, 'inspect-version', record, output]); assert.equal(await readFile(output, 'utf8'), `version=${version}\n`); @@ -94,7 +97,7 @@ test('the npm channel CLI advances across the checked-in product version boundar run(process.execPath, [ join(repoRoot, 'scripts', 'product-nightly.mjs'), 'assert-channel-advance', - '0.2.0-dev.43.20260830', + `${productVersion}-dev.43.20260830`, '0.1.0-dev.42.20260829', ]), ); @@ -125,7 +128,7 @@ test('the identity entrypoint runs before repository dependencies are installed' }, }); assert.deepEqual(JSON.parse(stdout), { - version: '0.2.0-dev.42.20260829', + version: `${productVersion}-dev.42.20260829`, sourceCommit: 'a'.repeat(40), }); }); diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index 126a5d55c1..59cbc4e1db 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -23,7 +23,7 @@ import { createReadStream, readFileSync } from 'node:fs'; import { access, mkdir, readFile, readdir } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { createServer } from 'node:net'; -import { join, resolve, sep } from 'node:path'; +import { join, posix, resolve, sep } from 'node:path'; import { ASSET_LICENSED_RENDERER_PACKAGES, collectProductionClosure, @@ -748,8 +748,12 @@ export async function smokePackagedRenderer( }, ); let stderr = ''; + let startupStderr = ''; + let stderrLength = 0; child.stderr.setEncoding('utf8'); child.stderr.on('data', (chunk) => { + startupStderr = `${startupStderr}${chunk}`.slice(0, 16_384); + stderrLength += chunk.length; stderr = `${stderr}${chunk}`.slice(-16_384); }); @@ -761,7 +765,11 @@ export async function smokePackagedRenderer( await exercisePackagedRendererMaximizeRestore(target, child); } } catch (error) { - throw new Error(`${error.message}${stderr.trim() ? `\n${stderr.trim()}` : ''}`); + const diagnostics = + stderrLength > 16_384 + ? `${startupStderr}\n[packaged-renderer] last stderr output:\n${stderr}` + : stderr; + throw new Error(`${error.message}${diagnostics.trim() ? `\n${diagnostics.trim()}` : ''}`); } finally { await stopChild(child); } @@ -799,23 +807,20 @@ function asarFilesUnder(header, prefix) { // Line-bounded on purpose: a lazy cross-line match reads the word `from` // inside a comment as an import and reports the prose that follows it. A // multi-line `import {` list is covered by its closing line. -const BARE_IMPORT_PATTERNS = [ +const IMPORT_PATTERNS = [ /^[ \t]*(?:import|export)[ \t]+(?:[^'"\n]*?[ \t]+from[ \t]+)?['"]([^'"\n]+)['"]/gm, /^[ \t]*\}[ \t]+from[ \t]+['"]([^'"\n]+)['"]/gm, /\b(?:import|require)\([ \t]*['"]([^'"\n]+)['"][ \t]*\)/g, ]; -/** Package names the given module text imports by name, ignoring builtins. */ -export function bareImportedPackages(source) { - const names = new Set(); - for (const pattern of BARE_IMPORT_PATTERNS) { +function importedSpecifiers(source) { + const specifiers = new Set(); + for (const pattern of IMPORT_PATTERNS) { for (const [, specifier] of source.matchAll(pattern)) { - if (/^[./]|^node:/.test(specifier)) continue; - const segments = specifier.split('/'); - names.add(specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]); + specifiers.add(specifier); } } - return names; + return specifiers; } // Provided by the Electron runtime rather than the archive's node_modules, so @@ -980,9 +985,20 @@ export async function assertPackagedDependencyClosure( const unresolvable = new Map(); for (const path of asarFilesUnder(asar().getRawHeader(asarPath).header, 'dist')) { if (!/\.(?:js|cjs|mjs)$/.test(path)) continue; - for (const name of bareImportedPackages( - asar().extractFile(asarPath, asarLookupPath(path)).toString('utf8'), - )) { + const source = asar().extractFile(asarPath, asarLookupPath(path)).toString('utf8'); + for (const specifier of importedSpecifiers(source)) { + if (specifier.startsWith('.')) { + const target = posix.normalize(posix.join(posix.dirname(path), specifier)); + try { + asar().statFile(asarPath, asarLookupPath(target)); + } catch { + throw new Error(`app.asar ships ${path} importing missing file ${target}`); + } + continue; + } + if (/^\/|^node:/.test(specifier)) continue; + const segments = specifier.split('/'); + const name = specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]; // Being in the closure is not enough — the code has to resolve at // runtime, and only the archive can answer that. A package that is // allowed but absent is exactly the ERR_MODULE_NOT_FOUND this check diff --git a/scripts/verify-packaged-app.test.mjs b/scripts/verify-packaged-app.test.mjs index fcba830a36..5aa2001aa8 100644 --- a/scripts/verify-packaged-app.test.mjs +++ b/scripts/verify-packaged-app.test.mjs @@ -42,6 +42,10 @@ import { stopChild, } from './verify-packaged-app.mjs'; +const { version: productVersion } = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8'), +); + test('stopChild waits for a process that ignores SIGTERM to exit before returning', { skip: process.platform === 'win32', }, async (t) => { @@ -500,7 +504,7 @@ test('accepts the Intel Mach-O architecture for an x64 package', async () => { 'provider: github\nowner: apache\nrepo: maka\nchannel: dev\nupdaterCacheDirName: "@makadesktop-updater"\n', ); await writeFile(join(resources, 'icon.icns'), RENDERABLE_ICNS); - const version = '0.2.0-dev.14.20260902'; + const version = `${productVersion}-dev.14.20260902`; const app = join(dirname(resources), 'Maka.app'); await mkdir(join(app, 'Contents'), { recursive: true }); await rename(resources, join(app, 'Contents', 'Resources')); @@ -711,6 +715,27 @@ describe('assertRenderableAppIcon', () => { }); describe('assertPackagedDependencyClosure', () => { + test('requires a relative runtime import to exist inside the shipped archive', async () => { + const distFiles = { + 'main/workhub-remote-bridge.js': + "import { readWorkHubTopicAssociation } from '../shared/workhub-topic-association.js';\n", + }; + await assert.rejects( + assertPackagedDependencyClosure(await makeResources({ distFiles }), options), + /workhub-remote-bridge\.js.*missing file.*shared\/workhub-topic-association\.js/u, + ); + await assertPackagedDependencyClosure( + await makeResources({ + distFiles: { + ...distFiles, + 'shared/workhub-topic-association.js': + 'export function readWorkHubTopicAssociation() {}\n', + }, + }), + options, + ); + }); + test('accepts an artifact whose asar, bundle record, and shipped notices match', async () => { const resources = await makeResources(); await assertPackagedDependencyClosure(resources, options);