change: an embedded app always runs its embedded payload
Two decisions land together because the code cannot compile between them: - Staging has no per-item skip. A stage failure throws and the build fails. The payload manifest shrinks to a complete-payload sentinel (schemaVersion 3, tag, commit). External builds write an external:true stub. - Backend selection is a constant of the artifact. resolvePayload requires every runtime item directory; when it resolves, the app spawns the embedded backend without a look at any checkout. A payload with no runnable interpreter is a damaged artifact and raises an error instead of a silent checkout fallback. decideResidentRuntime, the adoption-era checkout examination, and the installMode parameter of shouldUseAppUpdater are deleted. The app self-update gate is now: embedded stamp AND packaged. The update channel moves to config.yaml (update.channel); Electron mirrors it with a narrow parser for the version pill. The resident vocabulary is renamed to embedded; thin builds are now called external.
This commit is contained in:
parent
18478723f1
commit
60372c1630
|
|
@ -6,39 +6,16 @@ import { describeFeedCheck, shouldUseAppUpdater } from './app-updater'
|
|||
|
||||
// ── shouldUseAppUpdater ─────────────────────────────────────────────
|
||||
|
||||
test('app updater runs only for packaged bundled installs with payloads', () => {
|
||||
assert.equal(
|
||||
shouldUseAppUpdater({ stampHasPayload: true, installMode: 'bundled', isPackaged: true }),
|
||||
true
|
||||
)
|
||||
test('app updater runs for packaged embedded builds', () => {
|
||||
assert.equal(shouldUseAppUpdater({ stampHasPayload: true, isPackaged: true }), true)
|
||||
})
|
||||
|
||||
test('a thin build never uses the app updater', () => {
|
||||
assert.equal(
|
||||
shouldUseAppUpdater({ stampHasPayload: false, installMode: 'bundled', isPackaged: true }),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('a source or ejected checkout keeps the git update path', () => {
|
||||
// Eject writes installMode: source. The gate must fall through to git.
|
||||
assert.equal(
|
||||
shouldUseAppUpdater({ stampHasPayload: true, installMode: 'source', isPackaged: true }),
|
||||
false
|
||||
)
|
||||
// No manifest at all: a legacy checkout. Adoption may run later, but the
|
||||
// updater gate stays closed until the manifest says bundled.
|
||||
assert.equal(
|
||||
shouldUseAppUpdater({ stampHasPayload: true, installMode: null, isPackaged: true }),
|
||||
false
|
||||
)
|
||||
test('an external build never uses the app updater', () => {
|
||||
assert.equal(shouldUseAppUpdater({ stampHasPayload: false, isPackaged: true }), false)
|
||||
})
|
||||
|
||||
test('dev runs never use the app updater', () => {
|
||||
assert.equal(
|
||||
shouldUseAppUpdater({ stampHasPayload: true, installMode: 'bundled', isPackaged: false }),
|
||||
false
|
||||
)
|
||||
assert.equal(shouldUseAppUpdater({ stampHasPayload: true, isPackaged: false }), false)
|
||||
})
|
||||
|
||||
// ── describeFeedCheck ───────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// Bundled installs update through GitHub Releases: electron-updater reads
|
||||
// latest*.yml from the release that the desktop-bundled-release workflow
|
||||
// attached, downloads the new installer, and applies it. The swapped-in app
|
||||
// carries the new runtime in its own resources (resident mode), so there is
|
||||
// carries the new runtime in its own resources (embedded mode), so there is
|
||||
// no post-update install step at all.
|
||||
//
|
||||
// Source installs never reach this module. The callers gate on the install
|
||||
|
|
@ -17,21 +17,23 @@ import type { AppUpdater } from 'electron-updater'
|
|||
|
||||
export interface UpdaterGateFacts {
|
||||
stampHasPayload: boolean
|
||||
installMode: string | null // from .hermes-install.json; null = no manifest
|
||||
isPackaged: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this launch must use electron-updater for app updates.
|
||||
*
|
||||
* All three conditions are necessary:
|
||||
* - the build carries payloads (a thin build has no matching feed artifacts),
|
||||
* - the checkout opted into desktop management (installMode bundled) — an
|
||||
* ejected or source checkout keeps the git update path,
|
||||
* Both conditions are necessary:
|
||||
* - the build carries an embedded payload (an external build has no
|
||||
* matching feed artifacts),
|
||||
* - the app is packaged (dev runs have no app-update.yml).
|
||||
*
|
||||
* This is a constant of the artifact, not of machine state. An eject
|
||||
* replaces the whole app with a source-built external one (no embedded
|
||||
* stamp), so no "ejected embedded install" state exists to gate on.
|
||||
*/
|
||||
export function shouldUseAppUpdater(facts: UpdaterGateFacts): boolean {
|
||||
return facts.stampHasPayload === true && facts.installMode === 'bundled' && facts.isPackaged === true
|
||||
return facts.stampHasPayload === true && facts.isPackaged === true
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,163 +3,89 @@ import assert from 'node:assert/strict'
|
|||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
decideResidentRuntime,
|
||||
findResidentPython,
|
||||
EMBEDDED_RUNTIME_ITEMS,
|
||||
findEmbeddedPython,
|
||||
latestReleaseFromLsRemote,
|
||||
type PayloadInfo,
|
||||
resolveChannel,
|
||||
resolvePayload
|
||||
PAYLOAD_SCHEMA_VERSION,
|
||||
resolvePayload,
|
||||
updateChannelFromConfig
|
||||
} from '../electron/bundled-runtime'
|
||||
|
||||
// ─── resolvePayload ────────────────────────────────────────────────
|
||||
|
||||
const readerFor = (manifest: unknown) => (p: string) => {
|
||||
if (!p.endsWith('manifest.json')) {throw new Error('ENOENT')}
|
||||
if (!p.endsWith('manifest.json')) {
|
||||
throw new Error('ENOENT')
|
||||
}
|
||||
|
||||
return JSON.stringify(manifest)
|
||||
}
|
||||
|
||||
test('resolvePayload returns null for dev runs, thin stubs, and garbage', () => {
|
||||
const allDirsExist = () => true
|
||||
const noDirsExist = () => false
|
||||
|
||||
const completeManifest = { schemaVersion: PAYLOAD_SCHEMA_VERSION, tag: 'v1.2.3', commit: 'a'.repeat(40) }
|
||||
|
||||
test('resolvePayload returns null for dev runs, external stubs, and garbage', () => {
|
||||
assert.equal(resolvePayload(null), null)
|
||||
assert.equal(resolvePayload(undefined), null)
|
||||
assert.equal(resolvePayload('/res', readerFor({ schemaVersion: 1, thin: true, items: {} })), null)
|
||||
assert.equal(
|
||||
resolvePayload('/res', () => {
|
||||
throw new Error('ENOENT')
|
||||
}),
|
||||
resolvePayload('/res', readerFor({ schemaVersion: PAYLOAD_SCHEMA_VERSION, external: true }), allDirsExist),
|
||||
null
|
||||
)
|
||||
assert.equal(resolvePayload('/res', readerFor('not-an-object')), null)
|
||||
// A manifest with items but no staged item returns null (an all-skipped payload).
|
||||
assert.equal(
|
||||
resolvePayload('/res', readerFor({ tag: 'v1.0.0', items: { repo: { status: 'skipped' } } })),
|
||||
resolvePayload(
|
||||
'/res',
|
||||
() => {
|
||||
throw new Error('ENOENT')
|
||||
},
|
||||
allDirsExist
|
||||
),
|
||||
null
|
||||
)
|
||||
assert.equal(resolvePayload('/res', readerFor('not-an-object'), allDirsExist), null)
|
||||
})
|
||||
|
||||
test('resolvePayload rejects old-schema manifests', () => {
|
||||
// A schema-2 manifest comes from a pre-embedded artifact. The app and
|
||||
// its payload travel together, so a mismatch means a foreign artifact.
|
||||
assert.equal(
|
||||
resolvePayload('/res', readerFor({ schemaVersion: 2, tag: 'v1.0.0', items: { repo: { status: 'staged' } } }), allDirsExist),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('resolvePayload returns dir + tag for a real payload', () => {
|
||||
const p = resolvePayload('/res', readerFor({ tag: 'v1.2.3', items: { repo: { status: 'staged' } } }))
|
||||
test('resolvePayload rejects a payload with a missing item directory', () => {
|
||||
// Completeness is a build invariant; a missing directory here means a
|
||||
// damaged or truncated artifact.
|
||||
assert.equal(resolvePayload('/res', readerFor(completeManifest), noDirsExist), null)
|
||||
|
||||
// One missing item out of five is still a rejection.
|
||||
const allButUv = (p: string) => !p.endsWith('/uv')
|
||||
|
||||
assert.equal(resolvePayload('/res', readerFor(completeManifest), allButUv), null)
|
||||
})
|
||||
|
||||
test('resolvePayload returns dir + tag for a complete payload', () => {
|
||||
const p = resolvePayload('/res', readerFor(completeManifest), allDirsExist)
|
||||
|
||||
assert.ok(p)
|
||||
assert.match(p.dir, /agent-payload$/)
|
||||
assert.equal(p.tag, 'v1.2.3')
|
||||
})
|
||||
|
||||
// ─── decideResidentRuntime ─────────────────────────────────────────
|
||||
|
||||
const residentPayload = (overrides: Partial<PayloadInfo> = {}): PayloadInfo => ({
|
||||
dir: '/res/agent-payload',
|
||||
tag: 'v2.0.0',
|
||||
schemaVersion: 2,
|
||||
items: {
|
||||
repo: { status: 'staged' },
|
||||
uv: { status: 'staged' },
|
||||
python: { status: 'staged' },
|
||||
'site-packages': { status: 'staged' },
|
||||
node: { status: 'staged' }
|
||||
},
|
||||
...overrides
|
||||
test('the required items include uv — plugin lazy installs are mandatory', () => {
|
||||
assert.deepEqual([...EMBEDDED_RUNTIME_ITEMS].sort(), ['node', 'python', 'repo', 'site-packages', 'uv'])
|
||||
})
|
||||
|
||||
test('a complete schema-2 payload runs resident on a fresh machine', () => {
|
||||
const d = decideResidentRuntime({
|
||||
payload: residentPayload(),
|
||||
checkoutExists: false,
|
||||
checkoutManifest: null,
|
||||
markerSaysDesktop: false
|
||||
})
|
||||
// ─── findEmbeddedPython ────────────────────────────────────────────
|
||||
|
||||
assert.equal(d.resident, true)
|
||||
})
|
||||
|
||||
test('resident even over an old desktop-managed checkout (marker or bundled manifest)', () => {
|
||||
// Phase-1 materialized checkout: manifest says bundled.
|
||||
assert.equal(
|
||||
decideResidentRuntime({
|
||||
payload: residentPayload(),
|
||||
checkoutExists: true,
|
||||
checkoutManifest: { installMode: 'bundled' },
|
||||
markerSaysDesktop: true
|
||||
}).resident,
|
||||
true
|
||||
)
|
||||
// Pre-manifest desktop install: no manifest, but the desktop marker
|
||||
// proves provenance.
|
||||
assert.equal(
|
||||
decideResidentRuntime({
|
||||
payload: residentPayload(),
|
||||
checkoutExists: true,
|
||||
checkoutManifest: null,
|
||||
markerSaysDesktop: true
|
||||
}).resident,
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('never resident over a checkout the user owns', () => {
|
||||
// Ejected / deliberate source install.
|
||||
const ejected = decideResidentRuntime({
|
||||
payload: residentPayload(),
|
||||
checkoutExists: true,
|
||||
checkoutManifest: { installMode: 'source' },
|
||||
markerSaysDesktop: true
|
||||
})
|
||||
|
||||
assert.equal(ejected.resident, false)
|
||||
assert.match(ejected.reason, /source-managed/)
|
||||
|
||||
// CLI-first user: checkout exists, no manifest, no desktop marker.
|
||||
const cliFirst = decideResidentRuntime({
|
||||
payload: residentPayload(),
|
||||
checkoutExists: true,
|
||||
checkoutManifest: null,
|
||||
markerSaysDesktop: false
|
||||
})
|
||||
|
||||
assert.equal(cliFirst.resident, false)
|
||||
assert.match(cliFirst.reason, /CLI-first/)
|
||||
})
|
||||
|
||||
test('thin, pre-resident, and incomplete payloads never run resident', () => {
|
||||
assert.match(
|
||||
decideResidentRuntime({
|
||||
payload: null,
|
||||
checkoutExists: false,
|
||||
checkoutManifest: null,
|
||||
markerSaysDesktop: false
|
||||
}).reason,
|
||||
/thin/
|
||||
)
|
||||
// Phase-1 artifact: schemaVersion 1.
|
||||
assert.match(
|
||||
decideResidentRuntime({
|
||||
payload: residentPayload({ schemaVersion: 1 }),
|
||||
checkoutExists: false,
|
||||
checkoutManifest: null,
|
||||
markerSaysDesktop: false
|
||||
}).reason,
|
||||
/predates/
|
||||
)
|
||||
// uv is mandatory: runtime lazy installs for plugins depend on it.
|
||||
const noUv = residentPayload()
|
||||
noUv.items = { ...noUv.items, uv: { status: 'skipped' } }
|
||||
|
||||
const d = decideResidentRuntime({
|
||||
payload: noUv,
|
||||
checkoutExists: false,
|
||||
checkoutManifest: null,
|
||||
markerSaysDesktop: false
|
||||
})
|
||||
|
||||
assert.equal(d.resident, false)
|
||||
assert.match(d.reason, /missing: uv/)
|
||||
})
|
||||
|
||||
// ─── findResidentPython ────────────────────────────────────────────
|
||||
|
||||
test('findResidentPython picks the patch-versioned dir and needs a real binary', () => {
|
||||
test('findEmbeddedPython picks the patch-versioned dir and needs a real binary', () => {
|
||||
const fsStub = (dirs: string[], files: string[]) => ({
|
||||
readdirSync: (p: string) => {
|
||||
if (!p.endsWith('python')) {throw new Error('ENOENT')}
|
||||
if (!p.endsWith('python')) {
|
||||
throw new Error('ENOENT')
|
||||
}
|
||||
|
||||
return dirs
|
||||
},
|
||||
|
|
@ -167,7 +93,7 @@ test('findResidentPython picks the patch-versioned dir and needs a real binary',
|
|||
})
|
||||
|
||||
// Patch-versioned real dir wins over the minor alias (reverse sort).
|
||||
const python = findResidentPython(
|
||||
const python = findEmbeddedPython(
|
||||
'/res/agent-payload',
|
||||
'darwin',
|
||||
fsStub(
|
||||
|
|
@ -180,7 +106,7 @@ test('findResidentPython picks the patch-versioned dir and needs a real binary',
|
|||
|
||||
// No python dir at all → null, not a throw.
|
||||
assert.equal(
|
||||
findResidentPython('/res/agent-payload', 'darwin', {
|
||||
findEmbeddedPython('/res/agent-payload', 'darwin', {
|
||||
readdirSync: () => {
|
||||
throw new Error('ENOENT')
|
||||
},
|
||||
|
|
@ -195,7 +121,7 @@ test('findResidentPython picks the patch-versioned dir and needs a real binary',
|
|||
const winRoot = 'win-res/agent-payload'
|
||||
const winExpected = ['win-res/agent-payload', 'python', 'cpython-3.11.15-windows-x86_64-none', 'python.exe'].join('/')
|
||||
|
||||
const winPython = findResidentPython(
|
||||
const winPython = findEmbeddedPython(
|
||||
winRoot,
|
||||
'win32',
|
||||
fsStub(['cpython-3.11.15-windows-x86_64-none'], [winExpected]) as never
|
||||
|
|
@ -204,12 +130,27 @@ test('findResidentPython picks the patch-versioned dir and needs a real binary',
|
|||
assert.match(String(winPython), /python\.exe$/)
|
||||
})
|
||||
|
||||
test('channel: bundled is always stable, source carries its own, absent means main', () => {
|
||||
assert.equal(resolveChannel({ installMode: 'bundled', channel: 'main' }), 'stable')
|
||||
assert.equal(resolveChannel({ installMode: 'source', channel: 'stable' }), 'stable')
|
||||
assert.equal(resolveChannel({ installMode: 'source', channel: 'main' }), 'main')
|
||||
assert.equal(resolveChannel(null), 'main')
|
||||
assert.equal(resolveChannel({}), 'main')
|
||||
// ─── updateChannelFromConfig ───────────────────────────────────────
|
||||
|
||||
test('channel comes from update.channel in config.yaml; absent means main', () => {
|
||||
assert.equal(updateChannelFromConfig('update:\n channel: stable\n'), 'stable')
|
||||
assert.equal(updateChannelFromConfig('update:\n channel: "stable"\n'), 'stable')
|
||||
assert.equal(updateChannelFromConfig('update:\n channel: main\n'), 'main')
|
||||
assert.equal(updateChannelFromConfig('model:\n provider: nous\n'), 'main')
|
||||
assert.equal(updateChannelFromConfig(null), 'main')
|
||||
assert.equal(updateChannelFromConfig(''), 'main')
|
||||
})
|
||||
|
||||
test('channel parsing stays inside the update block', () => {
|
||||
// A channel key in ANOTHER block must not leak into the answer.
|
||||
const text = 'gateway:\n channel: stable\nupdate:\n interval: 1\nmodel:\n channel: stable\n'
|
||||
|
||||
assert.equal(updateChannelFromConfig(text), 'main')
|
||||
|
||||
// The update block ends at the next top-level key.
|
||||
const ended = 'update:\n interval: 1\nother:\n channel: stable\n'
|
||||
|
||||
assert.equal(updateChannelFromConfig(ended), 'main')
|
||||
})
|
||||
|
||||
// ── latestReleaseFromLsRemote ───────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// bundled-runtime.ts: decision logic for the bundled desktop runtime.
|
||||
// This module finds payloads, decides marker-tag invalidation, and decides
|
||||
// silent adoption for pristine legacy checkouts. Marker-tag invalidation
|
||||
// tells us when an app update forces offline re-materialization.
|
||||
// bundled-runtime.ts: pure helpers for the embedded desktop runtime.
|
||||
// An Embedded artifact carries the whole agent runtime in its resources
|
||||
// and ALWAYS spawns the backend from there — there is no decision contest
|
||||
// against checkouts. This module only answers: does a complete payload
|
||||
// exist (resolvePayload), where is its interpreter (findEmbeddedPython),
|
||||
// and what update channel applies (resolveChannel).
|
||||
//
|
||||
// Design: .hermes/plans/2026-08-05_desktop-bundled-payloads-channels-eject.md
|
||||
// (§1.4 adoption, §4.3 bundled update flow).
|
||||
// Design: .hermes/plans/2026-08-07_183000-two-axis-install-model.md.
|
||||
//
|
||||
// All functions in this file are pure, and the callers inject the
|
||||
// dependencies. Thus vitest covers the whole decision surface. The impure
|
||||
|
|
@ -18,20 +19,27 @@ import path from 'node:path'
|
|||
export interface PayloadInfo {
|
||||
dir: string
|
||||
tag: string | null
|
||||
schemaVersion: number | null
|
||||
items: Record<string, { status: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the agent-payload directory that ships in the resources of the
|
||||
* packaged app. Returns null for thin builds (a stub manifest with
|
||||
* thin:true), for dev runs (no resourcesPath), and for unreadable manifests.
|
||||
* Every caller treats null as "behave exactly like the current network
|
||||
* bootstrap".
|
||||
* packaged app. Returns null for external builds (a stub manifest with
|
||||
* external:true), for dev runs (no resourcesPath), for unreadable or
|
||||
* old-schema manifests, and for payloads with a missing item directory.
|
||||
* Item presence is a build-time invariant (staging fails the build on an
|
||||
* incomplete payload), so a missing directory here means a damaged or
|
||||
* truncated artifact — the caller reports it, it does not fall back.
|
||||
*/
|
||||
export function resolvePayload(
|
||||
resourcesPath: string | null | undefined,
|
||||
readFile: (p: string) => string = p => fs.readFileSync(p, 'utf8')
|
||||
readFile: (p: string) => string = p => fs.readFileSync(p, 'utf8'),
|
||||
dirExists: (p: string) => boolean = p => {
|
||||
try {
|
||||
return fs.statSync(p).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
): PayloadInfo | null {
|
||||
if (!resourcesPath) {
|
||||
return null
|
||||
|
|
@ -47,95 +55,42 @@ export function resolvePayload(
|
|||
return null
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || parsed.thin === true) {
|
||||
if (!parsed || typeof parsed !== 'object' || parsed.external === true) {
|
||||
return null
|
||||
}
|
||||
|
||||
const items = parsed.items && typeof parsed.items === 'object' ? parsed.items : {}
|
||||
const hasAny = Object.values(items).some((v: any) => v && v.status === 'staged')
|
||||
if (parsed.schemaVersion !== PAYLOAD_SCHEMA_VERSION) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!hasAny) {
|
||||
if (!EMBEDDED_RUNTIME_ITEMS.every(item => dirExists(path.join(dir, item)))) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
dir,
|
||||
tag: typeof parsed.tag === 'string' ? parsed.tag : null,
|
||||
schemaVersion: typeof parsed.schemaVersion === 'number' ? parsed.schemaVersion : null,
|
||||
items
|
||||
tag: typeof parsed.tag === 'string' ? parsed.tag : null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── resident runtime (plan: 2026-08-07_resources-resident-bundled-runtime) ──
|
||||
// The manifest schema this build understands. Staging writes the same
|
||||
// number (stage-agent-payloads.mjs); the app and its payload travel in the
|
||||
// same artifact, so a mismatch means a damaged or foreign artifact.
|
||||
export const PAYLOAD_SCHEMA_VERSION = 3
|
||||
|
||||
// The payload items a resident launch requires — all of them. uv never
|
||||
// installs the runtime (site-packages ships prebuilt), but runtime lazy
|
||||
// installs for plugins are a mandatory feature, and uv is what installs
|
||||
// them into the writable overlay. A payload without uv is an incomplete
|
||||
// artifact, not a degraded one.
|
||||
export const RESIDENT_RUNTIME_ITEMS = ['repo', 'uv', 'python', 'site-packages', 'node'] as const
|
||||
|
||||
export interface ResidentDecision {
|
||||
resident: boolean
|
||||
reason: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether this launch runs the backend directly out of the payload
|
||||
* in resources (resident) instead of a checkout at ~/.hermes/hermes-agent.
|
||||
*
|
||||
* Resident is the default for a complete schema-2 payload. The checkout
|
||||
* wins only when the user demonstrably owns it:
|
||||
* - its manifest says installMode:source (an eject, or a deliberate
|
||||
* install.sh run — both write that manifest), or
|
||||
* - it exists with NO manifest and NO desktop-written bootstrap marker,
|
||||
* which is the pre-manifest curl|bash cohort. CLI-first users keep
|
||||
* their install; the desktop never silently shadows it.
|
||||
*
|
||||
* A pre-manifest checkout WITH a desktop marker (old desktop installs)
|
||||
* and a phase-1 bundled checkout (manifest installMode:bundled) both go
|
||||
* resident: their materialized trees were desktop-managed anyway, and
|
||||
* nothing is deleted — the preference is reversible by eject.
|
||||
*/
|
||||
export function decideResidentRuntime(facts: {
|
||||
payload: PayloadInfo | null
|
||||
checkoutExists: boolean
|
||||
checkoutManifest: { installMode?: string } | null
|
||||
markerSaysDesktop: boolean
|
||||
}): ResidentDecision {
|
||||
const { payload, checkoutExists, checkoutManifest, markerSaysDesktop } = facts
|
||||
|
||||
if (!payload) {
|
||||
return { resident: false, reason: 'thin build (no payload)' }
|
||||
}
|
||||
|
||||
if ((payload.schemaVersion ?? 0) < 2) {
|
||||
return { resident: false, reason: 'payload predates the resident layout' }
|
||||
}
|
||||
|
||||
const missing = RESIDENT_RUNTIME_ITEMS.filter((item) => payload.items[item]?.status !== 'staged')
|
||||
|
||||
if (missing.length > 0) {
|
||||
return { resident: false, reason: `payload incomplete (missing: ${missing.join(', ')})` }
|
||||
}
|
||||
|
||||
if (checkoutManifest && checkoutManifest.installMode === 'source') {
|
||||
return { resident: false, reason: 'checkout at the active root is source-managed' }
|
||||
}
|
||||
|
||||
if (checkoutExists && !checkoutManifest && !markerSaysDesktop) {
|
||||
return { resident: false, reason: 'legacy checkout without desktop provenance (CLI-first user)' }
|
||||
}
|
||||
|
||||
return { resident: true, reason: 'complete resident payload' }
|
||||
}
|
||||
// The runtime items inside a complete embedded payload — all of them. uv
|
||||
// never installs the runtime (site-packages ships prebuilt), but runtime
|
||||
// lazy installs for plugins are a mandatory feature, and uv is what
|
||||
// installs them into the writable overlay. A payload without uv is an
|
||||
// incomplete artifact, not a degraded one.
|
||||
export const EMBEDDED_RUNTIME_ITEMS = ['repo', 'uv', 'python', 'site-packages', 'node'] as const
|
||||
|
||||
/**
|
||||
* Locate the payload CPython binary. The install directory is
|
||||
* patch-versioned (python/cpython-3.11.15-<triple>/...), so this scans
|
||||
* rather than hardcoding, and it verifies the binary exists.
|
||||
*/
|
||||
export function findResidentPython(
|
||||
export function findEmbeddedPython(
|
||||
payloadDir: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
fsImpl: Pick<typeof fs, 'readdirSync' | 'existsSync'> = fs
|
||||
|
|
@ -169,20 +124,47 @@ export function findResidentPython(
|
|||
// ─── update channel ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The update channel of a checkout. Mirrors the resolution in
|
||||
* hermes_cli/install_manifest.py: a bundled install is always stable, a
|
||||
* source manifest carries its own channel, and a missing or unreadable
|
||||
* manifest means main. The channel decides what the version pill compares
|
||||
* against. The install mode decides only the apply mechanism.
|
||||
* The update channel of a source checkout, read from config.yaml text
|
||||
* (`update.channel`). The CLI owns this key; Electron only mirrors it for
|
||||
* the version pill. Anything but an explicit `stable` means `main` — the
|
||||
* default channel. Embedded artifacts never call this: their updates are
|
||||
* release-fed by construction.
|
||||
*
|
||||
* The parser is deliberately narrow: find the top-level `update:` block,
|
||||
* then the first `channel:` inside it. config.yaml is machine-written
|
||||
* (`hermes config set update.channel ...`), so this shape is stable.
|
||||
*/
|
||||
export function resolveChannel(
|
||||
manifest: { installMode?: string; channel?: string } | null | undefined
|
||||
): 'stable' | 'main' {
|
||||
if (manifest?.installMode === 'bundled') {
|
||||
return 'stable'
|
||||
export function updateChannelFromConfig(configText: string | null | undefined): 'stable' | 'main' {
|
||||
if (!configText) {
|
||||
return 'main'
|
||||
}
|
||||
|
||||
return manifest?.channel === 'stable' ? 'stable' : 'main'
|
||||
let inUpdateBlock = false
|
||||
|
||||
for (const raw of configText.split('\n')) {
|
||||
const line = raw.replace(/\s+$/, '')
|
||||
|
||||
if (/^update:\s*$/.test(line)) {
|
||||
inUpdateBlock = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (inUpdateBlock) {
|
||||
// The block ends at the next top-level key (no leading whitespace).
|
||||
if (/^\S/.test(line)) {
|
||||
break
|
||||
}
|
||||
|
||||
const match = line.match(/^\s+channel:\s*["']?(stable|main)["']?\s*(#.*)?$/)
|
||||
|
||||
if (match) {
|
||||
return match[1] as 'stable' | 'main'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'main'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ import { shouldLatchBackendStartFailure, shouldLatchRemoteReauthFailure } from '
|
|||
import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform'
|
||||
import { decideBootstrapRepair } from './bootstrap-repair-guard'
|
||||
import { runBootstrap } from './bootstrap-runner'
|
||||
import { decideResidentRuntime, findResidentPython, latestReleaseFromLsRemote, resolveChannel, resolvePayload } from './bundled-runtime'
|
||||
import { findEmbeddedPython, latestReleaseFromLsRemote, resolvePayload, updateChannelFromConfig } from './bundled-runtime'
|
||||
import { applyConnectionChange, resolveTerminalConnection } from './connection-apply'
|
||||
import {
|
||||
authModeFromStatus,
|
||||
|
|
@ -2595,11 +2595,11 @@ async function checkUpdates() {
|
|||
}
|
||||
}
|
||||
|
||||
// Source install on the stable channel (an ejected bundled install, or a
|
||||
// manual channel switch): compare against the newest release tag, not
|
||||
// against the tip of main. A commits-behind-main count is meaningless
|
||||
// vocabulary on this channel and reads as an alarming +N.
|
||||
if (resolveChannel(readJson(INSTALL_MANIFEST_PATH) as any) === 'stable') {
|
||||
// Source install on the stable channel (an ejected install, or a manual
|
||||
// channel switch): compare against the newest release tag, not against
|
||||
// the tip of main. A commits-behind-main count is meaningless vocabulary
|
||||
// on this channel and reads as an alarming +N.
|
||||
if (updateChannelFromConfig(readTextOrNull(path.join(HERMES_HOME, 'config.yaml'))) === 'stable') {
|
||||
return checkStableChannelUpdates()
|
||||
}
|
||||
|
||||
|
|
@ -3798,6 +3798,14 @@ function readJson(filePath) {
|
|||
}
|
||||
}
|
||||
|
||||
function readTextOrNull(filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap-complete marker helpers. The marker is written by whichever
|
||||
// installer ran: install.ps1, install.sh, the Rust bootstrap installer, or the
|
||||
// first-launch bootstrap runner. It is provenance ("a bootstrap finished
|
||||
|
|
@ -3816,48 +3824,29 @@ function readBootstrapMarker() {
|
|||
return readJson(BOOTSTRAP_COMPLETE_MARKER)
|
||||
}
|
||||
|
||||
// ─── Bundled-runtime decisions (resident mode) ──────────────────────────────
|
||||
|
||||
const INSTALL_MANIFEST_PATH = path.join(ACTIVE_HERMES_ROOT, '.hermes-install.json')
|
||||
// ─── Embedded-runtime facts ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The resident-runtime decision for this launch: run the backend directly
|
||||
* out of the payload in resources, with no materialized checkout. Facts
|
||||
* are re-read on every call (cheap file reads) so an eject flips the
|
||||
* answer without an app restart.
|
||||
* The embedded payload of this artifact, or null on external builds.
|
||||
* Resolution re-reads cheap file facts on every call; the answer is a
|
||||
* constant of the artifact in practice (the payload ships inside the
|
||||
* sealed resources and never changes at runtime).
|
||||
*/
|
||||
function residentRuntimeDecision() {
|
||||
const marker = readBootstrapMarker() as any
|
||||
|
||||
return decideResidentRuntime({
|
||||
payload: resolvePayload(process.resourcesPath),
|
||||
checkoutExists: directoryExists(ACTIVE_HERMES_ROOT),
|
||||
checkoutManifest: readJson(INSTALL_MANIFEST_PATH) as any,
|
||||
// desktopVersion has been written by every desktop bootstrap since the
|
||||
// marker existed; its presence is desktop provenance for the checkout.
|
||||
markerSaysDesktop: Boolean(marker && typeof marker.desktopVersion === 'string')
|
||||
})
|
||||
function embeddedPayload() {
|
||||
return resolvePayload(process.resourcesPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when app updates go through electron-updater instead of git.
|
||||
* Reads the manifest on every call. An eject flips the manifest to source
|
||||
* mode, and the next check must honor that without an app restart.
|
||||
*
|
||||
* A resident launch is bundled BY CONSTRUCTION: the code came from the
|
||||
* app's own resources, so the checkout manifest (which may not even
|
||||
* exist) has no say.
|
||||
* A constant of the artifact: embedded builds self-update, external
|
||||
* builds never do. No machine state has a say — an eject replaces the
|
||||
* whole app with a source-built external one.
|
||||
*/
|
||||
function bundledUpdaterActive(): boolean {
|
||||
const stamp = INSTALL_STAMP as any
|
||||
|
||||
const manifest = residentRuntimeDecision().resident
|
||||
? { installMode: 'bundled' }
|
||||
: (readJson(INSTALL_MANIFEST_PATH) as any)
|
||||
|
||||
return shouldUseAppUpdater({
|
||||
stampHasPayload: Boolean(stamp && stamp.payload),
|
||||
installMode: manifest && typeof manifest.installMode === 'string' ? manifest.installMode : null,
|
||||
isPackaged: app.isPackaged
|
||||
})
|
||||
}
|
||||
|
|
@ -4127,7 +4116,7 @@ function createActiveBackend(backendArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
// createResidentBackend — run the backend directly out of the payload in
|
||||
// createEmbeddedBackend — run the backend directly out of the payload in
|
||||
// the app's resources. Nothing is materialized: the payload CPython's
|
||||
// hermes-bundle.pth resolves repo/ and site-packages/ relative to itself,
|
||||
// so the spawn needs NO PYTHONPATH and survives renames, Gatekeeper
|
||||
|
|
@ -4137,17 +4126,17 @@ function createActiveBackend(backendArgs) {
|
|||
// writes (codesign would break; the mount may be read-only anyway).
|
||||
// - HERMES_LAZY_INSTALL_TARGET: plugin/lazy deps install into a writable
|
||||
// overlay via the existing uv-pip --target machinery in lazy_deps.
|
||||
function createResidentBackend(backendArgs) {
|
||||
function createEmbeddedBackend(backendArgs) {
|
||||
const payload = resolvePayload(process.resourcesPath)
|
||||
|
||||
if (!payload) {
|
||||
return null
|
||||
}
|
||||
|
||||
const command = findResidentPython(payload.dir)
|
||||
const command = findEmbeddedPython(payload.dir)
|
||||
|
||||
if (!command) {
|
||||
rememberLog(`[resident] payload at ${payload.dir} has no runnable CPython; falling back to checkout resolution`)
|
||||
rememberLog(`[embedded] payload at ${payload.dir} has no runnable CPython — the artifact is damaged`)
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -4181,41 +4170,47 @@ function createResidentBackend(backendArgs) {
|
|||
|
||||
return {
|
||||
kind: 'python',
|
||||
label: `Hermes resident bundle (${payload.tag || 'untagged'})`,
|
||||
label: `Hermes embedded runtime (${payload.tag || 'untagged'})`,
|
||||
command,
|
||||
args: ['-m', 'hermes_cli.main', ...backendArgs],
|
||||
env,
|
||||
root: repoRoot,
|
||||
resident: true,
|
||||
embedded: true,
|
||||
bootstrap: false,
|
||||
shell: false
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHermesBackend(backendArgs) {
|
||||
// 0. Resident bundle — the payload in resources IS the runtime. When the
|
||||
// decision says resident, nothing is materialized, no bootstrap runs,
|
||||
// and the checkout (if any) is simply not preferred: reversible, and
|
||||
// an eject flips the decision on the next resolution. This must come
|
||||
// before adoption: adoption exists to migrate legacy checkouts into
|
||||
// the MATERIALIZED bundled path, which a resident launch has no use
|
||||
// for. The HERMES_DESKTOP_HERMES_ROOT escape hatch still wins — it
|
||||
// exists precisely to point a packaged app at a developer checkout.
|
||||
// 0. Embedded runtime — an Embedded artifact ALWAYS runs the backend out
|
||||
// of its own resources. No checkout examination, no contest: backend
|
||||
// selection is a constant of the artifact. Checkouts on the machine
|
||||
// belong to the CLI and are never consulted here. The
|
||||
// HERMES_DESKTOP_HERMES_ROOT escape hatch still wins — it exists
|
||||
// precisely to point a packaged app at a developer checkout.
|
||||
const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT)
|
||||
const resident = overrideRoot ? { resident: false, reason: 'HERMES_DESKTOP_HERMES_ROOT override' } : residentRuntimeDecision()
|
||||
const payload = overrideRoot ? null : embeddedPayload()
|
||||
|
||||
if (resident.resident) {
|
||||
const backend = createResidentBackend(backendArgs)
|
||||
if (payload) {
|
||||
const backend = createEmbeddedBackend(backendArgs)
|
||||
|
||||
if (backend) {
|
||||
rememberLog(`[resident] running from the app bundle: ${resident.reason}`)
|
||||
rememberLog(`[embedded] running from the app bundle (${payload.tag || 'untagged'})`)
|
||||
|
||||
return backend
|
||||
}
|
||||
// A complete manifest but an unusable payload (no CPython found) is a
|
||||
// broken artifact; fall through to the checkout/bootstrap chain.
|
||||
} else {
|
||||
rememberLog(`[resident] not resident: ${resident.reason}`)
|
||||
|
||||
// A payload that resolves but yields no runnable interpreter is a
|
||||
// damaged artifact. Do NOT fall through to a checkout: a silent
|
||||
// fallback hides the build defect. Surface the failure instead.
|
||||
throw new Error(
|
||||
`The embedded runtime at ${payload.dir} is damaged (no runnable CPython). ` +
|
||||
'Reinstall Hermes from the website.'
|
||||
)
|
||||
}
|
||||
|
||||
if (overrideRoot) {
|
||||
rememberLog('[embedded] skipped: HERMES_DESKTOP_HERMES_ROOT override')
|
||||
}
|
||||
|
||||
// 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* .hermes/plans/2026-08-07_resources-resident-bundled-runtime.md.
|
||||
*
|
||||
* Output: apps/desktop/build/agent-payload/
|
||||
* manifest.json schemaVersion, tag, commit, platform, arch, per-item status
|
||||
* manifest.json schemaVersion, tag, commit, platform, arch
|
||||
* repo/ plain source tree at the release tag (no .git),
|
||||
* plus the PREBUILT JS surfaces (ui-tui dist +
|
||||
* node_modules, web_dist) and the build stamp
|
||||
|
|
@ -22,11 +22,9 @@
|
|||
*
|
||||
* Gating: the script does nothing unless HERMES_DESKTOP_BUNDLED=1. That
|
||||
* variable is an internal build-time env for CI wiring, not user config.
|
||||
* Thus dev builds and current CI keep producing thin builds. You can skip
|
||||
* individual items with --skip=<item,item> for incremental CI caching.
|
||||
* The manifest.json records every skip. The desktop only runs resident when
|
||||
* every runtime item is staged; a partial payload falls back to the network
|
||||
* bootstrap path.
|
||||
* Thus dev builds and current CI keep producing external builds. There is
|
||||
* no per-item skip: an embedded payload is complete, or this script throws
|
||||
* and the build fails.
|
||||
*
|
||||
* The heavy work shells out to git, uv, and tar. The decision logic
|
||||
* (target resolution, pip arg construction, manifest shape) is exported as
|
||||
|
|
@ -39,14 +37,12 @@ import path from "node:path"
|
|||
|
||||
import { isMain } from "./utils.mjs"
|
||||
|
||||
export const PAYLOAD_SCHEMA_VERSION = 2
|
||||
export const PAYLOAD_SCHEMA_VERSION = 3
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, "..")
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, "..", "..")
|
||||
const OUT_DIR = path.join(DESKTOP_ROOT, "build", "agent-payload")
|
||||
|
||||
export const PAYLOAD_ITEMS = ["repo", "uv", "python", "site-packages", "node"]
|
||||
|
||||
/**
|
||||
* Map (process.platform, process.arch) to the uv, python-build-standalone,
|
||||
* and node target descriptors. There is one artifact per (os, arch) pair.
|
||||
|
|
@ -197,11 +193,12 @@ export function bannerExpectations(target) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resolve the release tag to stage. CI passes --tag=vX.Y.Z. Local runs can
|
||||
* fall back to `git describe` for smoke tests. When bundling was requested
|
||||
* and no tag exists, payload staging is a hard error. A bundled artifact
|
||||
* without a pinned tag produces un-adoptable, un-updatable installs.
|
||||
* without a pinned tag produces un-updatable installs.
|
||||
*/
|
||||
export function resolveTag(argv, describeFn) {
|
||||
const explicit = argv.find((a) => a.startsWith("--tag="))
|
||||
|
|
@ -221,37 +218,13 @@ export function resolveTag(argv, describeFn) {
|
|||
)
|
||||
}
|
||||
|
||||
export function parseSkips(argv) {
|
||||
const flag = argv.find((a) => a.startsWith("--skip="))
|
||||
if (!flag) return new Set()
|
||||
const skips = new Set(
|
||||
flag
|
||||
.slice("--skip=".length)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
for (const s of skips) {
|
||||
if (!PAYLOAD_ITEMS.includes(s)) {
|
||||
throw new Error(`unknown --skip item: ${s} (valid: ${PAYLOAD_ITEMS.join(", ")})`)
|
||||
}
|
||||
}
|
||||
return skips
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the manifest that describes the contents of the payload tree.
|
||||
* `items` records per-item presence. Thus the resident-runtime gate in
|
||||
* the Electron main process can require exactly the items it needs and
|
||||
* refuse to run resident from an incomplete artifact.
|
||||
* Build the manifest that marks a complete embedded payload. The Electron
|
||||
* main process treats its presence (schemaVersion match, external: absent)
|
||||
* as the payload-present sentinel. Completeness is a build-time invariant:
|
||||
* main() throws before this manifest is written when any stage fails.
|
||||
*/
|
||||
export function buildManifest({ tag, commit, target, staged, skipped }) {
|
||||
const items = {}
|
||||
for (const item of PAYLOAD_ITEMS) {
|
||||
items[item] = staged.includes(item)
|
||||
? { status: "staged" }
|
||||
: { status: "skipped", reason: skipped.has(item) ? "explicit-skip" : "failed" }
|
||||
}
|
||||
export function buildManifest({ tag, commit, target }) {
|
||||
return {
|
||||
schemaVersion: PAYLOAD_SCHEMA_VERSION,
|
||||
tag,
|
||||
|
|
@ -259,7 +232,6 @@ export function buildManifest({ tag, commit, target, staged, skipped }) {
|
|||
platform: target.platform,
|
||||
arch: target.arch,
|
||||
builtAt: new Date().toISOString(),
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -595,13 +567,12 @@ function main() {
|
|||
fs.mkdirSync(OUT_DIR, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(OUT_DIR, "manifest.json"),
|
||||
JSON.stringify({ schemaVersion: PAYLOAD_SCHEMA_VERSION, thin: true, items: {} }, null, 2) + "\n"
|
||||
JSON.stringify({ schemaVersion: PAYLOAD_SCHEMA_VERSION, external: true }, null, 2) + "\n"
|
||||
)
|
||||
console.log("[stage-agent-payloads] HERMES_DESKTOP_BUNDLED != 1 — wrote thin stub manifest")
|
||||
console.log("[stage-agent-payloads] HERMES_DESKTOP_BUNDLED != 1 — wrote external stub manifest")
|
||||
return
|
||||
}
|
||||
const target = resolveTargets()
|
||||
const skips = parseSkips(process.argv.slice(2))
|
||||
const tag = resolveTag(process.argv.slice(2), () => {
|
||||
try {
|
||||
return execSync("git describe --tags --exact-match", { cwd: REPO_ROOT, encoding: "utf8" }).trim()
|
||||
|
|
@ -611,46 +582,24 @@ function main() {
|
|||
})
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true })
|
||||
const staged = []
|
||||
let commit = null
|
||||
let payloadPython = null
|
||||
|
||||
const steps = {
|
||||
repo: () => {
|
||||
commit = stageRepo(tag, OUT_DIR)
|
||||
},
|
||||
uv: () => {
|
||||
payloadPython = stageUvAndPython(target, OUT_DIR)
|
||||
},
|
||||
python: () => {
|
||||
// The uv step stages python too (one uv invocation). Guard the
|
||||
// manifest: a --skip=uv run must not record python as staged.
|
||||
if (!payloadPython) {
|
||||
throw new Error("python: the uv step was skipped, so no interpreter was staged — skip python too")
|
||||
}
|
||||
},
|
||||
"site-packages": () => {
|
||||
stageSitePackages(target, OUT_DIR, payloadPython)
|
||||
// The glue that makes the payload interpreter resolve repo/ and
|
||||
// site-packages/ wherever the bundle sits. Written after both
|
||||
// stages exist so a failed staging run never leaves a .pth that
|
||||
// points at nothing.
|
||||
writeBundlePth(OUT_DIR, payloadPython)
|
||||
},
|
||||
node: () => stageNode(target, OUT_DIR),
|
||||
}
|
||||
// Every stage runs, in order. A failure throws and the build fails:
|
||||
// an embedded payload is complete, or it does not exist.
|
||||
console.log(`[stage-agent-payloads] staging: repo (${target.key}, ${tag})`)
|
||||
const commit = stageRepo(tag, OUT_DIR)
|
||||
console.log(`[stage-agent-payloads] staging: uv + python (${target.key}, ${tag})`)
|
||||
const payloadPython = stageUvAndPython(target, OUT_DIR)
|
||||
console.log(`[stage-agent-payloads] staging: site-packages (${target.key}, ${tag})`)
|
||||
stageSitePackages(target, OUT_DIR, payloadPython)
|
||||
// The glue that makes the payload interpreter resolve repo/ and
|
||||
// site-packages/ wherever the bundle sits. Written after both stages
|
||||
// exist so a failed staging run never leaves a .pth that points at
|
||||
// nothing.
|
||||
writeBundlePth(OUT_DIR, payloadPython)
|
||||
console.log(`[stage-agent-payloads] staging: node (${target.key}, ${tag})`)
|
||||
stageNode(target, OUT_DIR)
|
||||
|
||||
for (const item of PAYLOAD_ITEMS) {
|
||||
if (skips.has(item)) {
|
||||
console.log(`[stage-agent-payloads] skip: ${item}`)
|
||||
continue
|
||||
}
|
||||
console.log(`[stage-agent-payloads] staging: ${item} (${target.key}, ${tag})`)
|
||||
steps[item]()
|
||||
staged.push(item)
|
||||
}
|
||||
|
||||
const manifest = buildManifest({ tag, commit, target, staged, skipped: skips })
|
||||
const manifest = buildManifest({ tag, commit, target })
|
||||
fs.writeFileSync(path.join(OUT_DIR, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n")
|
||||
console.log(`[stage-agent-payloads] wrote ${path.join(OUT_DIR, "manifest.json")}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@ import {
|
|||
bannerExpectations,
|
||||
buildManifest,
|
||||
bundlePthLines,
|
||||
parseSkips,
|
||||
PAYLOAD_ITEMS,
|
||||
PAYLOAD_SCHEMA_VERSION,
|
||||
pipTargetArgs,
|
||||
pythonDirPattern,
|
||||
pythonRequest,
|
||||
|
|
@ -98,38 +97,23 @@ test('falls back to git describe only for exact release tags', () => {
|
|||
assert.throws(() => resolveTag([], () => null), /no release tag/)
|
||||
})
|
||||
|
||||
// ─── parseSkips ────────────────────────────────────────────────────
|
||||
|
||||
test('parseSkips accepts known items and rejects unknown ones', () => {
|
||||
assert.deepEqual([...parseSkips(['--skip=site-packages,node'])].sort(), ['node', 'site-packages'])
|
||||
assert.equal(parseSkips([]).size, 0)
|
||||
assert.throws(() => parseSkips(['--skip=venv']), /unknown --skip/)
|
||||
// Retired payload items must not silently no-op in CI caching configs.
|
||||
assert.throws(() => parseSkips(['--skip=wheels']), /unknown --skip/)
|
||||
})
|
||||
|
||||
// ─── buildManifest ─────────────────────────────────────────────────
|
||||
|
||||
test('manifest records staged vs explicitly-skipped vs failed per item', () => {
|
||||
test('the manifest is a complete-payload sentinel: schema, tag, commit', () => {
|
||||
const target = resolveTargets('linux', 'x64')
|
||||
const manifest = buildManifest({
|
||||
tag: 'v1.0.0',
|
||||
commit: 'a'.repeat(40),
|
||||
target,
|
||||
staged: ['repo', 'uv', 'python'],
|
||||
skipped: new Set(['site-packages'])
|
||||
target
|
||||
})
|
||||
|
||||
assert.equal(manifest.schemaVersion, PAYLOAD_SCHEMA_VERSION)
|
||||
assert.equal(manifest.tag, 'v1.0.0')
|
||||
// Invariant: every payload item has an entry. The resident-runtime gate
|
||||
// reads presence. An absent entry is ambiguous.
|
||||
for (const item of PAYLOAD_ITEMS) {
|
||||
assert.ok(manifest.items[item], item)
|
||||
}
|
||||
assert.equal(manifest.items.repo.status, 'staged')
|
||||
assert.equal(manifest.items['site-packages'].status, 'skipped')
|
||||
assert.equal(manifest.items['site-packages'].reason, 'explicit-skip')
|
||||
// node was not staged and not explicitly skipped, so its status is failed.
|
||||
assert.equal(manifest.items.node.reason, 'failed')
|
||||
assert.equal(manifest.commit, 'a'.repeat(40))
|
||||
assert.equal(manifest.platform, 'linux')
|
||||
// No per-item status exists: completeness is a build invariant, not a
|
||||
// runtime question. The external stub is the only other manifest shape.
|
||||
assert.equal('items' in manifest, false)
|
||||
})
|
||||
|
||||
// ─── arch guards ────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in New Issue