diff --git a/packages/mtmharness/src/client/index.test.ts b/packages/mtmharness/src/client/index.test.ts index 8732fa8..022d68f 100644 --- a/packages/mtmharness/src/client/index.test.ts +++ b/packages/mtmharness/src/client/index.test.ts @@ -9,7 +9,7 @@ type Registered = { component: unknown; }; -function clientBench(): { registered: Registered[]; cleanups: Array<() => void | Promise> } { +function clientBench(loopback = true): { registered: Registered[]; cleanups: Array<() => void | Promise> } { const registered: Registered[] = []; const cleanups: Array<() => void | Promise> = []; const snapshot = createDemoRegistry().getSnapshot(); @@ -36,7 +36,7 @@ function clientBench(): { registered: Registered[]; cleanups: Array<() => void | }; const ctx = { get(name: string) { - if (name === "connection") return { rpc: { call: async () => ({ ok: true, value: snapshot }) } }; + if (name === "connection") return { isLoopback: loopback, rpc: { call: async () => ({ ok: true, value: snapshot }) } }; throw new Error("unexpected service: " + name); }, provide() {}, @@ -146,6 +146,20 @@ describe("mtmharness browser half", () => { expect(inject).toEqual(["slots", "connection", "locale", "settingsScope"]); }); + it("only exposes update actions for loopback connections", () => { + const local = clientBench(true); + const localCard = local.registered.find((entry) => entry.options.key === "mtm-coding"); + const localFace = (localCard?.options.inject as (() => { hooks: { mtmCodingCard: { getSnapshot: () => { update: { available: boolean } } } } }) | undefined)?.(); + expect(localFace?.hooks.mtmCodingCard.getSnapshot().update.available).toBe(true); + for (const cleanup of local.cleanups.reverse()) void cleanup(); + + const remote = clientBench(false); + const remoteCard = remote.registered.find((entry) => entry.options.key === "mtm-coding"); + const remoteFace = (remoteCard?.options.inject as (() => { hooks: { mtmCodingCard: { getSnapshot: () => { update: { available: boolean } } } } }) | undefined)?.(); + expect(remoteFace?.hooks.mtmCodingCard.getSnapshot().update.available).toBe(false); + for (const cleanup of remote.cleanups.reverse()) void cleanup(); + }); + it("fails clearly when the Client connection service is unavailable", () => { const cleanups: Array<() => void | Promise> = []; const ctx = { diff --git a/packages/mtmharness/src/features/coding/client/MtmCodingCard.tsx b/packages/mtmharness/src/features/coding/client/MtmCodingCard.tsx index deec838..8aca701 100644 --- a/packages/mtmharness/src/features/coding/client/MtmCodingCard.tsx +++ b/packages/mtmharness/src/features/coding/client/MtmCodingCard.tsx @@ -98,6 +98,7 @@ export function MtmCodingCard(props: MtmCodingCardProps) { const disabled = !state.writable; const mode = state.fields.ponytailMode; const rtkMode = state.fields.rtkMode; + const update = state.update; return (
  • + + + + ) : null}
    {state.failed ? {t("saveFailed")} : null} {state.dirty ? {t("unsaved")} : null} diff --git a/packages/mtmharness/src/features/coding/client/controller.test.ts b/packages/mtmharness/src/features/coding/client/controller.test.ts new file mode 100644 index 0000000..7a53357 --- /dev/null +++ b/packages/mtmharness/src/features/coding/client/controller.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import type { MtmUpdateResponse } from "../../update/contract.ts"; +import { MtmCodingCardController } from "./controller.ts"; + +function settingsScope() { + let snapshot = { + status: "ready", + value: { codebaseMemoryEnabled: true, dynamicCanvasEnabled: false, codebaseMemoryAugmentHooks: true, modernGoEnabled: true, modernGoCommand: "", ponytailEnabled: true, ponytailMode: "full", ponytailSubagents: true, rtkMode: "auto", rtkAutoInstall: true, rtkCommand: "" }, + base: {}, + user: {}, + revision: 1, + writable: true, + mode: "host", + }; + return { + scope: { + getSnapshot: () => snapshot, + subscribe: () => () => {}, + async set(field: string, value: unknown) { snapshot = { ...snapshot, value: { ...snapshot.value, [field]: value }, user: { ...snapshot.user, [field]: value } }; }, + async unset(field: string) { const user = { ...snapshot.user }; delete user[field]; snapshot = { ...snapshot, user }; }, + }, + }; +} + +const response = (status: MtmUpdateResponse["status"], restartRequired = false): MtmUpdateResponse => ({ + currentVersion: "0.5.5", + latestVersion: "0.6.0", + status, + error: null, + restartRequired, +}); + +async function flush(): Promise { + await new Promise((resolve) => { queueMicrotask(resolve); }); + await new Promise((resolve) => { queueMicrotask(resolve); }); +} + +describe("MtmCodingCardController update actions", () => { + it("hides update actions without a loopback RPC", () => { + const { scope } = settingsScope(); + const controller = new MtmCodingCardController(scope as never); + expect(controller.inject().hooks.mtmCodingCard.getSnapshot().update).toMatchObject({ available: false, status: "idle" }); + controller.dispose(); + }); + + it("publishes checking, available, and restart-required states", async () => { + const { scope } = settingsScope(); + let resolveRpc: ((result: unknown) => void) | undefined; + const calls: unknown[] = []; + const rpc = { call: async (_channel: string, _endpoint: string, payload: unknown) => { + calls.push(payload); + return await new Promise((resolve) => { resolveRpc = resolve; }); + } }; + const controller = new MtmCodingCardController(scope as never, rpc as never); + const face = controller.inject(); + face.checkForUpdate(); + expect(face.hooks.mtmCodingCard.getSnapshot().update).toMatchObject({ available: true, checking: true, updating: false }); + resolveRpc!({ ok: true, value: response("available") }); + await flush(); + expect(face.hooks.mtmCodingCard.getSnapshot().update).toMatchObject({ status: "available", checking: false, error: null }); + face.updatePackage(); + expect(face.hooks.mtmCodingCard.getSnapshot().update.updating).toBe(true); + resolveRpc!({ ok: true, value: response("updated", true) }); + await flush(); + expect(face.hooks.mtmCodingCard.getSnapshot().update).toMatchObject({ status: "updated", updating: false, restartRequired: true }); + expect(calls).toEqual([ + { args: { kind: "check" } }, + { args: { kind: "update" } }, + ]); + controller.dispose(); + }); + + it("keeps the card alive and reports malformed Host responses as errors", async () => { + const { scope } = settingsScope(); + const rpc = { call: async () => ({ ok: true, value: { status: "available" } }) }; + const controller = new MtmCodingCardController(scope as never, rpc as never); + const face = controller.inject(); + face.checkForUpdate(); + await flush(); + expect(face.hooks.mtmCodingCard.getSnapshot().update).toMatchObject({ available: true, status: "failed", checking: false }); + expect(face.hooks.mtmCodingCard.getSnapshot().update.error).toContain("currentVersion"); + controller.dispose(); + }); +}); diff --git a/packages/mtmharness/src/features/coding/client/controller.ts b/packages/mtmharness/src/features/coding/client/controller.ts index 3a7c0d4..74ea64a 100644 --- a/packages/mtmharness/src/features/coding/client/controller.ts +++ b/packages/mtmharness/src/features/coding/client/controller.ts @@ -1,5 +1,7 @@ +import type { ClientConnectionRpc } from "@deepseek-ai/dsh-client-connection/client"; import type { SettingsScope, SnapshotStore } from "@deepseek-ai/dsh-client-runtime/client"; import { createSnapshotStore } from "@deepseek-ai/dsh-client-runtime/client"; +import { assertMtmUpdateResponse, MTM_UPDATE_CHANNEL, type MtmUpdateStatus } from "../../update/contract.js"; import type { MtmCodingSettings, PonytailMode, RtkMode } from "../types.js"; export const SETTINGS_NAMESPACE = "mtm-coding"; @@ -26,6 +28,17 @@ export interface FieldState { readonly invalid: boolean; } +export interface MtmUpdateCardState { + readonly available: boolean; + readonly checking: boolean; + readonly updating: boolean; + readonly currentVersion: string | null; + readonly latestVersion: string | null; + readonly status: MtmUpdateStatus | "idle"; + readonly error: string | null; + readonly restartRequired: boolean; +} + export interface MtmCodingCardState { readonly available: boolean; readonly writable: boolean; @@ -34,6 +47,7 @@ export interface MtmCodingCardState { readonly saving: boolean; readonly failed: boolean; readonly fields: Readonly>; + readonly update: MtmUpdateCardState; } export interface MtmCodingCardFace { @@ -42,6 +56,8 @@ export interface MtmCodingCardFace { readonly resetField: (field: MtmCodingField) => void; readonly save: () => void; readonly discard: () => void; + readonly checkForUpdate: () => void; + readonly updatePackage: () => void; } type StagedEdit = { readonly text: string; readonly clear: boolean }; @@ -77,12 +93,25 @@ function userHas(snapshot: ReturnType["getSnaps export class MtmCodingCardController { private readonly staged = new Map(); private readonly store: SnapshotStore; + private readonly updateRpc: ClientConnectionRpc | undefined; + private updateState: MtmUpdateCardState; private saving = false; private failed = false; private disposed = false; private readonly unsubscribe: () => void; - constructor(private readonly scope: SettingsScope) { + constructor(private readonly scope: SettingsScope, updateRpc?: ClientConnectionRpc) { + this.updateRpc = updateRpc; + this.updateState = { + available: updateRpc !== undefined, + checking: false, + updating: false, + currentVersion: null, + latestVersion: null, + status: "idle", + error: null, + restartRequired: false, + }; this.store = createSnapshotStore(this.projection()); this.unsubscribe = scope.subscribe(() => { this.publish(); }); } @@ -94,6 +123,8 @@ export class MtmCodingCardController { resetField: (field) => { this.resetField(field); }, save: () => { void this.save(); }, discard: () => { this.discard(); }, + checkForUpdate: () => { void this.requestUpdate("check"); }, + updatePackage: () => { void this.requestUpdate("update"); }, }; } @@ -102,6 +133,33 @@ export class MtmCodingCardController { this.unsubscribe(); } + private async requestUpdate(kind: "check" | "update"): Promise { + const rpc = this.updateRpc; + if (rpc === undefined || this.updateState.checking || this.updateState.updating) return; + this.updateState = { + ...this.updateState, + checking: kind === "check", + updating: kind === "update", + error: null, + }; + this.publish(); + try { + const result = await rpc.call(MTM_UPDATE_CHANNEL, "request", { args: { kind } }); + if (!result.ok) throw new Error(result.error.message); + assertMtmUpdateResponse(result.value); + this.updateState = { ...result.value, available: true, checking: false, updating: false }; + } catch (error) { + this.updateState = { + ...this.updateState, + checking: false, + updating: false, + status: "failed", + error: error instanceof Error ? error.message : String(error), + }; + } + this.publish(); + } + private edit(field: MtmCodingField, text: string): void { this.staged.set(field, { text, clear: false }); this.failed = false; @@ -199,6 +257,7 @@ export class MtmCodingCardController { saving: this.saving, failed: this.failed, fields, + update: this.updateState, }; } diff --git a/packages/mtmharness/src/features/coding/client/index.tsx b/packages/mtmharness/src/features/coding/client/index.tsx index 27546a2..a899306 100644 --- a/packages/mtmharness/src/features/coding/client/index.tsx +++ b/packages/mtmharness/src/features/coding/client/index.tsx @@ -1,3 +1,4 @@ +import type { ConnectionHandle } from "@deepseek-ai/dsh-client-connection/client"; import type { ClientContext } from "@deepseek-ai/dsh-client-runtime/client"; import type {} from "@deepseek-ai/dsh-client-locale/client"; import type {} from "@deepseek-ai/dsh-client-ui-settings/client"; @@ -15,12 +16,14 @@ declare module "@deepseek-ai/dsh-client-ui-slots" { } export const name = "mtm-coding-client"; -export const inject = ["slots", "locale", "settingsScope"]; +export const inject = ["slots", "locale", "settingsScope", "connection"]; export function apply(ctx: ClientContext): void { const t = ctx.locale.bind("mtm.coding"); ctx.effect(() => ctx.locale.register("mtm.coding", { en, zh }), "mtm-coding: locale"); - const controller = new MtmCodingCardController(ctx.settingsScope.bind({ namespace: SETTINGS_NAMESPACE })); + const connection = typeof ctx.get === "function" ? ctx.get("connection") as ConnectionHandle | undefined : undefined; + const updateRpc = connection?.isLoopback === true ? connection.rpc : undefined; + const controller = new MtmCodingCardController(ctx.settingsScope.bind({ namespace: SETTINGS_NAMESPACE }), updateRpc); ctx.effect(() => () => { controller.dispose(); }, "mtm-coding: settings card"); ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({ name: "settings.plugin.item", diff --git a/packages/mtmharness/src/features/coding/client/locales.ts b/packages/mtmharness/src/features/coding/client/locales.ts index 4ab641d..77c3262 100644 --- a/packages/mtmharness/src/features/coding/client/locales.ts +++ b/packages/mtmharness/src/features/coding/client/locales.ts @@ -41,7 +41,19 @@ export type MtmCodingLocaleKey = | "saveFailed" | "readOnly" | "show" - | "hide"; + | "hide" + | "updateTitle" + | "updateHint" + | "currentVersion" + | "latestVersion" + | "checkForUpdates" + | "checkingForUpdates" + | "updateNow" + | "updatingPackage" + | "updateAvailable" + | "upToDate" + | "updateComplete" + | "restartRequired"; export const en: Record = { nav: "Coding", @@ -87,6 +99,18 @@ export const en: Record = { readOnly: "This deployment stores settings read-only.", show: "Show settings", hide: "Hide settings", + updateTitle: "mtmharness update", + updateHint: "Check the stable npm release installed in this DSH Web profile. Updating requires a host restart.", + currentVersion: "Current version", + latestVersion: "Latest version", + checkForUpdates: "Check for updates", + checkingForUpdates: "Checking...", + updateNow: "Update now", + updatingPackage: "Updating...", + updateAvailable: "A newer stable version is available.", + upToDate: "This profile is up to date.", + updateComplete: "Update installed.", + restartRequired: "Restart DSH Web to load the update.", }; export const zh: Record = { @@ -133,4 +157,16 @@ export const zh: Record = { readOnly: "本部署的设置为只读。", show: "展开设置", hide: "收起设置", + updateTitle: "mtmharness 更新", + updateHint: "检查此 DSH Web profile 中安装的稳定版 npm 包;更新后需要重启 Host。", + currentVersion: "当前版本", + latestVersion: "最新版本", + checkForUpdates: "检查更新", + checkingForUpdates: "检查中...", + updateNow: "立即更新", + updatingPackage: "更新中...", + updateAvailable: "有新的稳定版可用。", + upToDate: "此 profile 已是最新版本。", + updateComplete: "更新已安装。", + restartRequired: "请重启 DSH Web 以加载更新。", }; diff --git a/packages/mtmharness/src/features/update/contract.ts b/packages/mtmharness/src/features/update/contract.ts new file mode 100644 index 0000000..d013d03 --- /dev/null +++ b/packages/mtmharness/src/features/update/contract.ts @@ -0,0 +1,59 @@ +export const MTM_UPDATE_CHANNEL = "/mtm-update"; + +export type MtmUpdateRpcRequest = + | { readonly kind: "check" } + | { readonly kind: "update" }; + +export type MtmUpdateStatus = "current" | "available" | "updated" | "ahead" | "unavailable" | "failed"; + +export interface MtmUpdateResponse { + readonly currentVersion: string | null; + readonly latestVersion: string | null; + readonly status: MtmUpdateStatus; + readonly error: string | null; + readonly restartRequired: boolean; +} + +const VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; +const STATUSES: readonly MtmUpdateStatus[] = ["current", "available", "updated", "ahead", "unavailable", "failed"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function exactKeys(value: Record, allowed: readonly string[], label: string): void { + const allowedSet = new Set(allowed); + for (const key of Object.keys(value)) { + if (!allowedSet.has(key)) throw new Error(label + " contains unsupported field: " + key); + } +} + +function versionValue(value: unknown, label: string): string | null { + if (value === null) return null; + if (typeof value !== "string" || !VERSION_PATTERN.test(value)) throw new Error(label + " must be a stable semantic version or null"); + return value; +} + +export function parseMtmUpdateRpcRequest(value: unknown): MtmUpdateRpcRequest { + if (!isRecord(value) || typeof value.kind !== "string") throw new Error("mtm-update RPC request is invalid"); + switch (value.kind) { + case "check": + exactKeys(value, ["kind"], "check request"); + return { kind: "check" }; + case "update": + exactKeys(value, ["kind"], "update request"); + return { kind: "update" }; + default: + throw new Error("unsupported mtm-update RPC request"); + } +} + +export function assertMtmUpdateResponse(value: unknown): asserts value is MtmUpdateResponse { + if (!isRecord(value)) throw new Error("mtm-update RPC returned an invalid response"); + exactKeys(value, ["currentVersion", "latestVersion", "status", "error", "restartRequired"], "mtm-update response"); + versionValue(value.currentVersion, "currentVersion"); + versionValue(value.latestVersion, "latestVersion"); + if (!STATUSES.includes(value.status as MtmUpdateStatus)) throw new Error("mtm-update response status is invalid"); + if (typeof value.error !== "string" && value.error !== null) throw new Error("mtm-update response error is invalid"); + if (typeof value.restartRequired !== "boolean") throw new Error("mtm-update response restartRequired is invalid"); +} diff --git a/packages/mtmharness/src/features/update/index.test.ts b/packages/mtmharness/src/features/update/index.test.ts new file mode 100644 index 0000000..4205fb6 --- /dev/null +++ b/packages/mtmharness/src/features/update/index.test.ts @@ -0,0 +1,198 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { + assertMtmUpdateResponse, + MTM_UPDATE_CHANNEL, + parseMtmUpdateRpcRequest, + type MtmUpdateResponse, +} from "./contract.ts"; +import { apply, createMtmUpdateRpcHandler } from "./index.ts"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function profile(version = "0.5.5"): string { + const root = mkdtempSync(join(tmpdir(), "mtm-update-test-")); + roots.push(root); + mkdirSync(join(root, "node_modules", "mtmharness"), { recursive: true }); + writeFileSync(join(root, "package.json"), JSON.stringify({ dsh: { profile: { bundles: ["mtmharness"] } }, dependencies: { mtmharness: version } })); + writeFileSync(join(root, "node_modules", "mtmharness", "package.json"), JSON.stringify({ name: "mtmharness", version })); + return root; +} + +interface FakeOptions { + readonly latestOutput?: string; + readonly latestExitCode?: number; + readonly updateExitCode?: number; + readonly updateVersion?: string; + readonly updateDelayMs?: number; + readonly missingPnpm?: boolean; +} + +function fakeContext(profileDir: string, options: FakeOptions = {}) { + const calls: Array<{ readonly argv: readonly string[]; readonly cwd: string }> = []; + let active = 0; + let maxActive = 0; + let updateCount = 0; + const subprocess = { + async resolveExecutable(command: string, _env?: unknown, signal?: AbortSignal) { + expect(command).toBe("pnpm"); + if (signal?.aborted) throw new Error("cancelled"); + if (options.missingPnpm) throw new Error("missing pnpm"); + return "/usr/bin/pnpm"; + }, + spawn(spec: { readonly argv: readonly string[]; readonly cwd: string }) { + calls.push(spec); + active += 1; + maxActive = Math.max(maxActive, active); + const isView = spec.argv.includes("view"); + if (!isView) updateCount += 1; + const output = isView ? options.latestOutput ?? "\"0.6.0\"\n" : ""; + const exitCode = isView ? options.latestExitCode ?? 0 : options.updateExitCode ?? 0; + const delay = isView ? 0 : options.updateDelayMs ?? 0; + const done = new Promise<{ readonly exitCode: number; readonly signal: null }>((resolve) => { + setTimeout(() => { + if (!isView && options.updateVersion !== undefined) { + writeFileSync(join(profileDir, "node_modules", "mtmharness", "package.json"), JSON.stringify({ name: "mtmharness", version: options.updateVersion })); + } + active -= 1; + resolve({ exitCode, signal: null }); + }, delay); + }); + const reader = { readFrom: () => ({ text: output, nextOffset: output.length, lossy: false }) }; + return { + collected: { stdout: reader, stderr: { readFrom: () => ({ text: "", nextOffset: 0, lossy: false }) } }, + done, + terminate() {}, + }; + }, + }; + return { + ctx: { baseUrl: pathToFileURL(profileDir).href, subprocess }, + calls, + stats: () => ({ maxActive, updateCount }), + }; +} + +function responseValue(result: unknown): MtmUpdateResponse { + const rpc = result as { readonly ok: boolean; readonly value?: unknown }; + expect(rpc.ok).toBe(true); + return rpc.value as MtmUpdateResponse; +} + +describe("mtm-update contract", () => { + it("accepts only parameterless operations and validates stable responses", () => { + expect(parseMtmUpdateRpcRequest({ kind: "check" })).toEqual({ kind: "check" }); + expect(parseMtmUpdateRpcRequest({ kind: "update" })).toEqual({ kind: "update" }); + expect(() => parseMtmUpdateRpcRequest({ kind: "check", package: "evil" })).toThrow("unsupported field"); + expect(() => parseMtmUpdateRpcRequest({ kind: "update", version: "1.0.0" })).toThrow("unsupported field"); + expect(() => assertMtmUpdateResponse({ + currentVersion: "1.0.0-beta.1", + latestVersion: null, + status: "available", + error: null, + restartRequired: false, + })).toThrow("stable semantic version"); + expect(() => assertMtmUpdateResponse({ + currentVersion: "1.0.0", + latestVersion: "1.1.0", + status: "available", + error: null, + restartRequired: false, + command: "rm -rf /", + })).toThrow("unsupported field"); + }); +}); + +describe("mtm-update Host", () => { + it("registers a loopback-only channel and removes it on cleanup", async () => { + const profileDir = profile(); + let registration: { channel: string; options: unknown } | undefined; + let removed = false; + const cleanups: Array<() => void | Promise> = []; + const fake = fakeContext(profileDir); + const ctx = { + ...fake.ctx, + connection: { + rpc: { + handle(channel: string, _handler: unknown, options: unknown) { + registration = { channel, options }; + return async () => { removed = true; }; + }, + }, + }, + effect(effect: () => (() => void | Promise) | void) { + const cleanup = effect(); + if (typeof cleanup === "function") cleanups.push(cleanup); + return cleanup; + }, + }; + apply(ctx as never); + expect(registration).toEqual({ channel: MTM_UPDATE_CHANNEL, options: { authority: "loopback" } }); + for (const cleanup of cleanups.reverse()) await cleanup(); + expect(removed).toBe(true); + }); + + it("checks and updates the active profile with fixed package-manager arguments", async () => { + const profileDir = profile(); + const fake = fakeContext(profileDir, { updateVersion: "0.6.0" }); + const handler = createMtmUpdateRpcHandler(fake.ctx as never); + const signal = new AbortController().signal; + + const checked = responseValue(await handler("request", { args: { kind: "check" } }, signal)); + expect(checked).toEqual({ currentVersion: "0.5.5", latestVersion: "0.6.0", status: "available", error: null, restartRequired: false }); + + const updated = responseValue(await handler("request", { args: { kind: "update" } }, signal)); + expect(updated).toEqual({ currentVersion: "0.6.0", latestVersion: "0.6.0", status: "updated", error: null, restartRequired: true }); + expect(fake.calls.map((call) => call.argv)).toEqual([ + ["/usr/bin/pnpm", "view", "mtmharness@latest", "version", "--json"], + ["/usr/bin/pnpm", "view", "mtmharness@latest", "version", "--json"], + ["/usr/bin/pnpm", "update", "mtmharness", "--latest"], + ]); + expect(fake.calls.every((call) => call.cwd === profileDir)).toBe(true); + }); + + it("reports unavailable and failed states without asking the process to exit", async () => { + const missingPnpm = fakeContext(profile(), { missingPnpm: true }); + const missingResult = responseValue(await createMtmUpdateRpcHandler(missingPnpm.ctx as never)("request", { args: { kind: "check" } }, new AbortController().signal)); + expect(missingResult).toMatchObject({ status: "unavailable", error: "mtm-update: pnpm is unavailable", restartRequired: false }); + + const registryFailure = fakeContext(profile(), { latestExitCode: 1 }); + const registryResult = responseValue(await createMtmUpdateRpcHandler(registryFailure.ctx as never)("request", { args: { kind: "check" } }, new AbortController().signal)); + expect(registryResult).toMatchObject({ status: "unavailable", currentVersion: "0.5.5", error: "mtm-update: npm registry check failed" }); + + const cancelled = fakeContext(profile()); + const cancelledResult = responseValue(await createMtmUpdateRpcHandler(cancelled.ctx as never)("request", { args: { kind: "check" } }, AbortSignal.abort())); + expect(cancelledResult).toMatchObject({ status: "unavailable", error: "mtm-update: operation cancelled" }); + + const failedUpdate = fakeContext(profile(), { updateExitCode: 1 }); + const failedResult = responseValue(await createMtmUpdateRpcHandler(failedUpdate.ctx as never)("request", { args: { kind: "update" } }, new AbortController().signal)); + expect(failedResult).toMatchObject({ status: "failed", currentVersion: "0.5.5", latestVersion: "0.6.0", restartRequired: false }); + }); + + it("rejects malformed installed manifests and serializes concurrent updates", async () => { + const malformedDir = profile(); + writeFileSync(join(malformedDir, "node_modules", "mtmharness", "package.json"), "not json"); + const malformed = fakeContext(malformedDir); + const malformedResult = responseValue(await createMtmUpdateRpcHandler(malformed.ctx as never)("request", { args: { kind: "check" } }, new AbortController().signal)); + expect(malformedResult).toMatchObject({ status: "unavailable", error: "mtm-update: installed mtmharness manifest is invalid" }); + + const concurrentDir = profile(); + const concurrent = fakeContext(concurrentDir, { updateVersion: "0.6.0", updateDelayMs: 10 }); + const handler = createMtmUpdateRpcHandler(concurrent.ctx as never); + const request = { args: { kind: "update" } }; + const [first, second] = await Promise.all([ + handler("request", request, new AbortController().signal), + handler("request", request, new AbortController().signal), + ]); + expect(responseValue(first).status).toBe("updated"); + expect(responseValue(second)).toMatchObject({ status: "current", currentVersion: "0.6.0", restartRequired: true }); + expect(concurrent.stats()).toEqual({ maxActive: 1, updateCount: 1 }); + }); +}); diff --git a/packages/mtmharness/src/features/update/index.ts b/packages/mtmharness/src/features/update/index.ts new file mode 100644 index 0000000..af77ddc --- /dev/null +++ b/packages/mtmharness/src/features/update/index.ts @@ -0,0 +1,232 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Context } from "@deepseek-ai/cordis"; +import { runCollected } from "../coding/runtime.js"; +import { MTM_UPDATE_CHANNEL, parseMtmUpdateRpcRequest, type MtmUpdateResponse } from "./contract.js"; + +const PACKAGE_NAME = "mtmharness"; +const UPDATE_TIMEOUT_MS = 300_000; +const VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; + +type RpcResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: { readonly code: "internal"; readonly message: string; readonly details: Record } }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stableVersion(value: unknown): string | undefined { + return typeof value === "string" && VERSION_PATTERN.test(value) ? value : undefined; +} + +function versionParts(version: string): [number, number, number] { + const parts = version.split(".").map(Number); + return [parts[0]!, parts[1]!, parts[2]!]; +} + +function compareVersions(left: string, right: string): number { + const a = versionParts(left); + const b = versionParts(right); + for (let index = 0; index < a.length; index += 1) { + if (a[index]! !== b[index]!) return a[index]! - b[index]!; + } + return 0; +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +function failure(error: unknown): RpcResult { + return { + ok: false, + error: { + code: "internal", + message: errorMessage(error, "mtm-update request failed"), + details: {}, + }, + }; +} + +function profileDirFromContext(ctx: Context): string { + if (ctx.baseUrl === undefined) throw new Error("mtm-update: active DSH profile is unavailable"); + let url: URL; + try { + url = new URL(ctx.baseUrl); + } catch { + throw new Error("mtm-update: active DSH profile URL is invalid"); + } + if (url.protocol !== "file:") throw new Error("mtm-update: active DSH profile is not file-backed"); + return fileURLToPath(url); +} + +async function readProfileManifest(profileDir: string): Promise { + let raw: string; + try { + raw = await readFile(join(profileDir, "package.json"), "utf8"); + } catch { + throw new Error("mtm-update: active DSH profile manifest is unavailable"); + } + let manifest: unknown; + try { + manifest = JSON.parse(raw); + } catch { + throw new Error("mtm-update: active DSH profile manifest is invalid"); + } + if (!isRecord(manifest)) throw new Error("mtm-update: active DSH profile manifest is invalid"); + const dsh = isRecord(manifest.dsh) ? manifest.dsh : undefined; + const profile = dsh !== undefined && isRecord(dsh.profile) ? dsh.profile : undefined; + const dependencies = manifest.dependencies; + if (profile === undefined || !Array.isArray(profile.bundles) || !profile.bundles.includes(PACKAGE_NAME) + || !isRecord(dependencies) || !Object.hasOwn(dependencies, PACKAGE_NAME)) { + throw new Error("mtm-update: mtmharness is not an active DSH profile dependency"); + } +} + +async function readInstalledVersion(profileDir: string): Promise { + await readProfileManifest(profileDir); + let raw: string; + try { + raw = await readFile(join(profileDir, "node_modules", PACKAGE_NAME, "package.json"), "utf8"); + } catch { + throw new Error("mtm-update: mtmharness is not installed in the active profile"); + } + let manifest: unknown; + try { + manifest = JSON.parse(raw); + } catch { + throw new Error("mtm-update: installed mtmharness manifest is invalid"); + } + const version = isRecord(manifest) && manifest.name === PACKAGE_NAME ? stableVersion(manifest.version) : undefined; + if (version === undefined) throw new Error("mtm-update: installed mtmharness manifest is invalid"); + return version; +} + +async function runPnpm(ctx: Context, profileDir: string, args: readonly string[], signal: AbortSignal) { + let executable: string; + try { + executable = await ctx.subprocess.resolveExecutable("pnpm", undefined, signal); + } catch { + if (signal.aborted) throw new Error("mtm-update: operation cancelled"); + throw new Error("mtm-update: pnpm is unavailable"); + } + try { + return await runCollected(ctx, [executable, ...args], profileDir, {}, "", UPDATE_TIMEOUT_MS, signal); + } catch { + if (signal.aborted) throw new Error("mtm-update: operation cancelled"); + throw new Error("mtm-update: package manager could not start"); + } +} + +function parseLatestVersion(output: string): string { + let value: unknown = output.trim(); + try { + value = JSON.parse(output.trim()); + } catch { + // pnpm without JSON quoting still emits one plain version line. + } + if (Array.isArray(value) && value.length === 1) value = value[0]; + const version = stableVersion(value); + if (version === undefined) throw new Error("mtm-update: npm registry returned an invalid stable version"); + return version; +} + +async function readLatestVersion(ctx: Context, profileDir: string, signal: AbortSignal): Promise { + const result = await runPnpm(ctx, profileDir, ["view", PACKAGE_NAME + "@latest", "version", "--json"], signal); + if (signal.aborted) throw new Error("mtm-update: operation cancelled"); + if (result.timedOut) throw new Error("mtm-update: npm registry check timed out"); + if (result.outcome.exitCode !== 0 || result.outcome.signal !== null) throw new Error("mtm-update: npm registry check failed"); + return parseLatestVersion(result.stdout); +} + +function unavailable(currentVersion: string | null, latestVersion: string | null, error: unknown): MtmUpdateResponse { + return { currentVersion, latestVersion, status: "unavailable", error: errorMessage(error, "mtm-update is unavailable"), restartRequired: false }; +} + +function failed(currentVersion: string | null, latestVersion: string | null, error: unknown, restartRequired: boolean): MtmUpdateResponse { + return { currentVersion, latestVersion, status: "failed", error: errorMessage(error, "mtm-update failed"), restartRequired }; +} + +async function check(ctx: Context, signal: AbortSignal): Promise { + let profileDir: string; + let currentVersion: string; + try { + profileDir = profileDirFromContext(ctx); + currentVersion = await readInstalledVersion(profileDir); + } catch (error) { + return unavailable(null, null, error); + } + try { + const latestVersion = await readLatestVersion(ctx, profileDir, signal); + const comparison = compareVersions(currentVersion, latestVersion); + return { + currentVersion, + latestVersion, + status: comparison === 0 ? "current" : comparison > 0 ? "ahead" : "available", + error: null, + restartRequired: false, + }; + } catch (error) { + return unavailable(currentVersion, null, error); + } +} + +async function update(ctx: Context, signal: AbortSignal, markRestartRequired: () => void): Promise { + const checked = await check(ctx, signal); + if (checked.status !== "available" || checked.latestVersion === null) return checked; + let profileDir: string; + try { + profileDir = profileDirFromContext(ctx); + const result = await runPnpm(ctx, profileDir, ["update", PACKAGE_NAME, "--latest"], signal); + if (signal.aborted) return failed(checked.currentVersion, checked.latestVersion, new Error("mtm-update: operation cancelled"), false); + if (result.timedOut || result.outcome.exitCode !== 0 || result.outcome.signal !== null) { + return failed(checked.currentVersion, checked.latestVersion, new Error("mtm-update: package manager update failed"), false); + } + const installedVersion = await readInstalledVersion(profileDir); + if (installedVersion !== checked.latestVersion) { + return failed(installedVersion, checked.latestVersion, new Error("mtm-update: installed version did not match the registry"), false); + } + markRestartRequired(); + return { ...checked, currentVersion: installedVersion, status: "updated", error: null, restartRequired: true }; + } catch (error) { + return failed(checked.currentVersion, checked.latestVersion, error, false); + } +} + +/** Install the Host-mediated mtmharness profile updater on DSH's loopback RPC. */ +export const name = "mtm-update"; +export const inject = ["connection", "subprocess"]; + +export function createMtmUpdateRpcHandler(ctx: Context): (endpoint: string, payload: unknown, signal: AbortSignal) => Promise> { + let restartRequired = false; + let tail: Promise = Promise.resolve(); + + const enqueue = (job: () => Promise): Promise => { + const result = tail.then(job, job); + tail = result.then(() => undefined, () => undefined); + return result; + }; + + return async (endpoint, payload, signal) => { + if (endpoint !== "request") return failure(new Error("mtm-update: unknown RPC endpoint")); + try { + const request = parseMtmUpdateRpcRequest((payload as { args?: unknown } | null)?.args); + const value = await enqueue(() => request.kind === "check" + ? check(ctx, signal) + : update(ctx, signal, () => { restartRequired = true; })); + return { ok: true, value: { ...value, restartRequired: value.restartRequired || restartRequired } }; + } catch (error) { + return failure(error); + } + }; +} + +export function apply(ctx: Context): void { + const handler = createMtmUpdateRpcHandler(ctx); + ctx.effect(() => { + const remove = ctx.connection.rpc.handle(MTM_UPDATE_CHANNEL, handler, { authority: "loopback" }); + return async () => { await remove(); }; + }, "mtm-update: Host RPC"); +} diff --git a/packages/mtmharness/src/index.ts b/packages/mtmharness/src/index.ts index 20bdeda..33d499a 100644 --- a/packages/mtmharness/src/index.ts +++ b/packages/mtmharness/src/index.ts @@ -2,6 +2,7 @@ import type { Context } from "@deepseek-ai/cordis"; import { apply as applyCodingHost } from "./features/coding/index.ts"; import { apply as applyConnectHost } from "./features/connect/index.ts"; +import { apply as applyUpdateHost } from "./features/update/index.ts"; export { buildMcpConfig, resolveConfig } from "./features/coding/index.ts"; export { MODERN_GO_RESOURCE_BASE, createModernGoSkill } from "./features/coding/modern-go.ts"; @@ -42,11 +43,12 @@ export type { } from "./features/secondary/client.ts"; export const name = "mtmharness"; -export const inject = ["connection", "settings"]; +export const inject = ["connection", "settings", "subprocess"]; /** Mount the Host-owned MTM and coding control planes. */ export async function apply(ctx: Context, config: Record = {}): Promise { if (ctx.connection === undefined) throw new Error("mtmharness: DSH connection service is unavailable"); applyConnectHost(ctx); + applyUpdateHost(ctx); await applyCodingHost(ctx, config); }