mirror-mind 0.38.0 → 0.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/releases/index.md +1 -0
- package/docs/releases/v0.33.3.md +9 -1
- package/docs/releases/v0.38.1.md +152 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/plugins/mirror-mind/plugin.json +1 -1
- package/ts/src/frontDoor/cli.ts +32 -103
- package/ts/src/frontDoor/consultRoute.ts +4 -2
- package/ts/src/frontDoor/dbPath.ts +80 -7
- package/ts/src/frontDoor/initRoute.ts +190 -0
- package/ts/src/frontDoor/mirrorHomes.ts +95 -0
- package/ts/src/frontDoor/namedRefusal.ts +29 -1
- package/ts/src/frontDoor/runtimeRoute.ts +9 -5
- package/ts/src/frontDoor/searchRoute.ts +31 -3
- package/ts/src/frontDoor/usage.ts +17 -3
- package/ts/src/init/init.ts +2 -1
- package/ts/src/providers/config.ts +88 -1
- package/ts/src/providers/embedding.ts +26 -3
- package/ts/src/providers/familyProviders.ts +43 -0
- package/ts/src/runtime/diagnose.ts +28 -1
- package/ts/src/runtime/git.ts +153 -42
- package/ts/src/runtime/status.ts +27 -17
- package/ts/src/runtime/strategies/clone.ts +76 -55
- package/ts/src/runtime/update.ts +2 -1
- package/ts/src/search/memorySearch.ts +4 -2
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* are required to replay" and "these are the providers this family uses".
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { requireOpenRouterKey } from "#providers/config.ts";
|
|
18
19
|
import {
|
|
19
20
|
type CreditProvider,
|
|
20
21
|
LiveCreditProvider,
|
|
@@ -110,6 +111,48 @@ export async function resolveFamilyProviders(
|
|
|
110
111
|
: liveProviders(env, owner, decision, overrides);
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* `resolveFamilyProviders` for a command that cannot do its job without the
|
|
116
|
+
* key (CR187): `consult`, `journal`, `week plan`, `descriptor generate`,
|
|
117
|
+
* `consolidate scan` and `apply`, and `shadow scan`.
|
|
118
|
+
*
|
|
119
|
+
* The contract is the call site's: it is the route's first statement, before
|
|
120
|
+
* the database is opened or anything is read, so the refusal's "nothing was
|
|
121
|
+
* done" -- or the `consequence` a route names instead -- is true. Live mode only:
|
|
122
|
+
* a replay fixture needs no key. The family returned is the one
|
|
123
|
+
* `resolveFamilyProviders` builds.
|
|
124
|
+
*
|
|
125
|
+
* A command that can still answer without the key (search, `build load`,
|
|
126
|
+
* `mirror load`) keeps calling `resolveFamilyProviders`, and degrades.
|
|
127
|
+
*/
|
|
128
|
+
export async function requireFamilyProviders(
|
|
129
|
+
env: ProviderTransportEnv,
|
|
130
|
+
spec: FamilyTransport,
|
|
131
|
+
options: { consequence?: string; overrides?: FamilyProviderOverrides } = {},
|
|
132
|
+
): Promise<FamilyProviders> {
|
|
133
|
+
requireKeyForFamily(env, spec, options);
|
|
134
|
+
return resolveFamilyProviders(env, spec, options.overrides);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The key check alone, from the transport decision, with no provider built. For
|
|
139
|
+
* a route that must refuse before a step that comes ahead of its providers:
|
|
140
|
+
* `consult` ask opens its ledger database first, and that open takes the
|
|
141
|
+
* pre-write snapshot. Half a replay fixture is left to `resolveFamilyProviders`,
|
|
142
|
+
* which refuses it by name.
|
|
143
|
+
*/
|
|
144
|
+
export function requireKeyForFamily(
|
|
145
|
+
env: ProviderTransportEnv,
|
|
146
|
+
spec: FamilyTransport,
|
|
147
|
+
options: { consequence?: string } = {},
|
|
148
|
+
): void {
|
|
149
|
+
const decision = isComposed(spec)
|
|
150
|
+
? resolveComposedProviderTransport(env, spec)
|
|
151
|
+
: resolveProviderTransport(env, spec);
|
|
152
|
+
if (decision.mode !== "live") return;
|
|
153
|
+
requireOpenRouterKey(env, options.consequence === undefined ? {} : options);
|
|
154
|
+
}
|
|
155
|
+
|
|
113
156
|
async function replayProviders(
|
|
114
157
|
decision: ProviderTransportDecision,
|
|
115
158
|
overrides: FamilyProviderOverrides,
|
|
@@ -34,7 +34,12 @@ import {
|
|
|
34
34
|
readExtensionPlugin,
|
|
35
35
|
} from "#extensions/extensionPlugin.ts";
|
|
36
36
|
import { identityKeyBreaksGrammar, SLUG_KEYED_LAYERS } from "#identity/identityKey.ts";
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
hasOpenRouterKey,
|
|
39
|
+
keyFileInstruction,
|
|
40
|
+
REASONING_EFFORTS,
|
|
41
|
+
resolveExtractionReasoning,
|
|
42
|
+
} from "#providers/config.ts";
|
|
38
43
|
import { pyRepr, sortByCodePoint } from "#util/pythonText.ts";
|
|
39
44
|
import { configFilePath } from "./config.ts";
|
|
40
45
|
import { extensionPluginStates } from "./extensionPluginWiring.ts";
|
|
@@ -772,6 +777,28 @@ export function staleRevertGateFindings(env: NodeJS.ProcessEnv): DriftFinding[]
|
|
|
772
777
|
];
|
|
773
778
|
}
|
|
774
779
|
|
|
780
|
+
/**
|
|
781
|
+
* No OpenRouter key (CR187), read by the predicate every refusal uses, so
|
|
782
|
+
* diagnose and the refusals cannot disagree about what "configured" means.
|
|
783
|
+
* Presence only: the value is never read into a finding. Attention, not a
|
|
784
|
+
* blocker -- a keyless install still runs and updates; it just remembers
|
|
785
|
+
* nothing new and answers no `consult`. The recommendation names the file the
|
|
786
|
+
* key goes in, and says to make it owner-only when it does not exist yet.
|
|
787
|
+
*/
|
|
788
|
+
export function openRouterKeyFindings(env: NodeJS.ProcessEnv, home?: string): DriftFinding[] {
|
|
789
|
+
if (hasOpenRouterKey(env)) return [];
|
|
790
|
+
return [
|
|
791
|
+
{
|
|
792
|
+
code: "openrouter_key_missing",
|
|
793
|
+
severity: "attention",
|
|
794
|
+
subject: "OPENROUTER_API_KEY",
|
|
795
|
+
detail: "not set, so embeddings, memory extraction, and consult are off",
|
|
796
|
+
recommendation: `add ${keyFileInstruction(env, home)}`,
|
|
797
|
+
repair_route: "configuration",
|
|
798
|
+
},
|
|
799
|
+
];
|
|
800
|
+
}
|
|
801
|
+
|
|
775
802
|
/**
|
|
776
803
|
* A `MEMORY_EXTRACTION_REASONING` that is not a reasoning effort (CV9.DS9.TS1).
|
|
777
804
|
*
|
package/ts/src/runtime/git.ts
CHANGED
|
@@ -84,6 +84,17 @@ export interface UpdateAvailability {
|
|
|
84
84
|
target_release: null;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* How a caller asks git for something. `runGit` is one; the update pipeline
|
|
89
|
+
* injects others, so its decisions can be driven in tests without a remote.
|
|
90
|
+
* Without a timeout, a command gets `runGit`'s local budget.
|
|
91
|
+
*/
|
|
92
|
+
export type GitRunner = (
|
|
93
|
+
args: readonly string[],
|
|
94
|
+
cwd: string,
|
|
95
|
+
timeoutMs?: number,
|
|
96
|
+
) => { code: number; stdout: string; stderr: string };
|
|
97
|
+
|
|
87
98
|
/** `(exit code, trimmed stdout, trimmed stderr)`, tolerant like the oracle's. */
|
|
88
99
|
export function runGit(
|
|
89
100
|
args: readonly string[],
|
|
@@ -217,6 +228,16 @@ export function upstreamFor(channel: MarkerValue): string {
|
|
|
217
228
|
return `origin/${channel.value}`;
|
|
218
229
|
}
|
|
219
230
|
|
|
231
|
+
/** `origin/main` -> `["origin", "main"]`; anything else is refused. */
|
|
232
|
+
export function splitUpstream(upstream: string): [string, string] | null {
|
|
233
|
+
const index = upstream.indexOf("/");
|
|
234
|
+
if (index <= 0 || index === upstream.length - 1) return null;
|
|
235
|
+
const remote = upstream.slice(0, index);
|
|
236
|
+
const branch = upstream.slice(index + 1);
|
|
237
|
+
if (remote.startsWith("-") || branch.startsWith("-")) return null;
|
|
238
|
+
return [remote, branch];
|
|
239
|
+
}
|
|
240
|
+
|
|
220
241
|
// Lives in `runtime/version.ts` now -- the MCP server needs it and should not
|
|
221
242
|
// import this module to get it. Re-exported so runtime call sites do not move.
|
|
222
243
|
export { packageVersion } from "./version.ts";
|
|
@@ -338,6 +359,117 @@ export function checkUpdateAvailability(
|
|
|
338
359
|
});
|
|
339
360
|
}
|
|
340
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Where a checkout stands against the channel branch it follows: one reading
|
|
364
|
+
* for both the update plan (`strategies/clone.ts`) and `inspectGitUpdatePlan`,
|
|
365
|
+
* so the two cannot disagree (CR186). `ahead` counts the checkout's commits
|
|
366
|
+
* that the upstream lacks; `behind`, the upstream's commits that the checkout lacks.
|
|
367
|
+
*/
|
|
368
|
+
export type ChannelPosition =
|
|
369
|
+
/** The two could not be compared; `reason` is git's, when it gave one. */
|
|
370
|
+
| { kind: "unreadable"; reason: string }
|
|
371
|
+
| { kind: "current" }
|
|
372
|
+
/** The channel has commits to take, and the checkout has none of its own. */
|
|
373
|
+
| { kind: "pull"; behind: number }
|
|
374
|
+
/**
|
|
375
|
+
* Every commit past the channel is already on the other channel's branch:
|
|
376
|
+
* the channel is behind the checkout, and nothing here is the person's own.
|
|
377
|
+
* Release promotion only fast-forwards `stable` to `main`, so this is a
|
|
378
|
+
* `stable` clone that took `main`'s commits before their release.
|
|
379
|
+
*/
|
|
380
|
+
| {
|
|
381
|
+
kind: "channel_behind";
|
|
382
|
+
ahead: number;
|
|
383
|
+
channel: string;
|
|
384
|
+
carriedBy: string;
|
|
385
|
+
carriedByRef: string;
|
|
386
|
+
}
|
|
387
|
+
/** At least one commit past the channel is on no channel's branch. */
|
|
388
|
+
| { kind: "local_commits"; ahead: number }
|
|
389
|
+
| { kind: "diverged"; ahead: number; behind: number };
|
|
390
|
+
|
|
391
|
+
type CommitCount = { value: number } | { reason: string };
|
|
392
|
+
|
|
393
|
+
function countCommits(
|
|
394
|
+
repository: string,
|
|
395
|
+
revisions: readonly string[],
|
|
396
|
+
git: GitRunner,
|
|
397
|
+
): CommitCount {
|
|
398
|
+
const result = git(["rev-list", "--count", ...revisions], repository);
|
|
399
|
+
const value = Number.parseInt(result.stdout.trim(), 10);
|
|
400
|
+
if (result.code !== 0 || Number.isNaN(value)) {
|
|
401
|
+
return { reason: result.stderr.trim() || "cannot compare upstream" };
|
|
402
|
+
}
|
|
403
|
+
return { value };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The other channel whose branch already has every commit past `upstream`, if
|
|
408
|
+
* one does. Its ref is read as last fetched. A stale or missing ref can only
|
|
409
|
+
* make published commits look local, which is the refusal a clone always had;
|
|
410
|
+
* it can never make local work look published.
|
|
411
|
+
*/
|
|
412
|
+
function channelBehind(
|
|
413
|
+
repository: string,
|
|
414
|
+
upstream: string,
|
|
415
|
+
ahead: number,
|
|
416
|
+
git: GitRunner,
|
|
417
|
+
): ChannelPosition | null {
|
|
418
|
+
const split = splitUpstream(upstream);
|
|
419
|
+
if (split === null) return null;
|
|
420
|
+
const [remote, channel] = split;
|
|
421
|
+
if (!KNOWN_UPDATE_CHANNELS.has(channel)) return null;
|
|
422
|
+
for (const other of KNOWN_UPDATE_CHANNELS) {
|
|
423
|
+
if (other === channel) continue;
|
|
424
|
+
const ref = `${remote}/${other}`;
|
|
425
|
+
const unpublished = countCommits(repository, [`${upstream}..HEAD`, `^${ref}`], git);
|
|
426
|
+
if ("value" in unpublished && unpublished.value === 0) {
|
|
427
|
+
return { kind: "channel_behind", ahead, channel, carriedBy: other, carriedByRef: ref };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Read from the local object database only: the plan, not the remote check. */
|
|
434
|
+
export function readChannelPosition(
|
|
435
|
+
repository: string,
|
|
436
|
+
upstream: string,
|
|
437
|
+
git: GitRunner = runGit,
|
|
438
|
+
): ChannelPosition {
|
|
439
|
+
const ahead = countCommits(repository, [`${upstream}..HEAD`], git);
|
|
440
|
+
if ("reason" in ahead) return { kind: "unreadable", reason: ahead.reason };
|
|
441
|
+
const behind = countCommits(repository, [`HEAD..${upstream}`], git);
|
|
442
|
+
if ("reason" in behind) return { kind: "unreadable", reason: behind.reason };
|
|
443
|
+
if (ahead.value === 0) {
|
|
444
|
+
return behind.value === 0 ? { kind: "current" } : { kind: "pull", behind: behind.value };
|
|
445
|
+
}
|
|
446
|
+
if (behind.value > 0) return { kind: "diverged", ahead: ahead.value, behind: behind.value };
|
|
447
|
+
return (
|
|
448
|
+
channelBehind(repository, upstream, ahead.value, git) ?? {
|
|
449
|
+
kind: "local_commits",
|
|
450
|
+
ahead: ahead.value,
|
|
451
|
+
}
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** The words both readers give a position: the plan's detail, the inspection's note. */
|
|
456
|
+
export function describeChannelPosition(position: ChannelPosition): string {
|
|
457
|
+
switch (position.kind) {
|
|
458
|
+
case "unreadable":
|
|
459
|
+
return position.reason;
|
|
460
|
+
case "current":
|
|
461
|
+
return "already up to date";
|
|
462
|
+
case "pull":
|
|
463
|
+
return `pull ${position.behind} remote commit(s)`;
|
|
464
|
+
case "channel_behind":
|
|
465
|
+
return `'${position.channel}' is behind this checkout (${position.ahead} commit(s), all on ${position.carriedByRef})`;
|
|
466
|
+
case "local_commits":
|
|
467
|
+
return "local commits present";
|
|
468
|
+
case "diverged":
|
|
469
|
+
return "branch diverged";
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
341
473
|
/**
|
|
342
474
|
* Port of `inspect_git_update_plan`: how far HEAD is from the channel's
|
|
343
475
|
* upstream, and whether a fast-forward is the honest move.
|
|
@@ -381,49 +513,28 @@ export function inspectGitUpdatePlan(
|
|
|
381
513
|
}
|
|
382
514
|
}
|
|
383
515
|
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
if (ahead === 0 && behind > 0) {
|
|
407
|
-
return {
|
|
408
|
-
upstream,
|
|
409
|
-
ahead,
|
|
410
|
-
behind,
|
|
411
|
-
ready: true,
|
|
412
|
-
action: "pull",
|
|
413
|
-
note: `pull ${behind} remote commit(s)`,
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
if (ahead > 0 && behind === 0) {
|
|
417
|
-
return {
|
|
418
|
-
upstream,
|
|
419
|
-
ahead,
|
|
420
|
-
behind,
|
|
421
|
-
ready: false,
|
|
422
|
-
action: "blocked",
|
|
423
|
-
note: "local commits present",
|
|
424
|
-
};
|
|
516
|
+
const position = readChannelPosition(git.repository, upstream);
|
|
517
|
+
const note = describeChannelPosition(position);
|
|
518
|
+
switch (position.kind) {
|
|
519
|
+
case "unreadable":
|
|
520
|
+
return blocked(upstream, note);
|
|
521
|
+
case "current":
|
|
522
|
+
return { upstream, ahead: 0, behind: 0, ready: true, action: "none", note };
|
|
523
|
+
case "pull":
|
|
524
|
+
return { upstream, ahead: 0, behind: position.behind, ready: true, action: "pull", note };
|
|
525
|
+
case "diverged":
|
|
526
|
+
return {
|
|
527
|
+
upstream,
|
|
528
|
+
ahead: position.ahead,
|
|
529
|
+
behind: position.behind,
|
|
530
|
+
ready: false,
|
|
531
|
+
action: "blocked",
|
|
532
|
+
note,
|
|
533
|
+
};
|
|
534
|
+
case "channel_behind":
|
|
535
|
+
case "local_commits":
|
|
536
|
+
return { upstream, ahead: position.ahead, behind: 0, ready: false, action: "blocked", note };
|
|
425
537
|
}
|
|
426
|
-
return { upstream, ahead, behind, ready: false, action: "blocked", note: "branch diverged" };
|
|
427
538
|
}
|
|
428
539
|
|
|
429
540
|
/** A package's git facts: it has none, and none is not an error. */
|
package/ts/src/runtime/status.ts
CHANGED
|
@@ -25,7 +25,11 @@ import { readExtensionPlugin } from "#extensions/extensionPlugin.ts";
|
|
|
25
25
|
import { loadExtensionManifest } from "#extensions/manifest.ts";
|
|
26
26
|
import { inspectMigrationFiles } from "#extensions/migrations.ts";
|
|
27
27
|
import { type DbPathEnv, dbNameForEnv, resolveMirrorHome } from "#frontDoor/dbPath.ts";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_EMBEDDING_MODEL,
|
|
30
|
+
hasOpenRouterKey,
|
|
31
|
+
resolveExtractionReasoning,
|
|
32
|
+
} from "#providers/config.ts";
|
|
29
33
|
import { sortByCodePoint } from "#util/pythonText.ts";
|
|
30
34
|
import { channelForReport } from "./channel.ts";
|
|
31
35
|
import type { MarkerValue } from "./git.ts";
|
|
@@ -373,28 +377,30 @@ export interface BuildStatusOptions {
|
|
|
373
377
|
* front door with no npm probe (see `installKind.ts`).
|
|
374
378
|
*/
|
|
375
379
|
install?: InstallKind;
|
|
380
|
+
/**
|
|
381
|
+
* The OS user's home, where the homes root and the config file are found when
|
|
382
|
+
* no home is configured; `composeWelcome` takes the same seam. The golden tests
|
|
383
|
+
* pin it, since the not-configured sentence names what is found there.
|
|
384
|
+
*/
|
|
385
|
+
osHome?: string;
|
|
376
386
|
}
|
|
377
387
|
|
|
378
388
|
/**
|
|
379
389
|
* Python's `resolve_mirror_home` raises `ValueError`, and
|
|
380
|
-
* `build_runtime_status` prints `str(exc)` as `Mirror home note:`.
|
|
381
|
-
*
|
|
382
|
-
* front
|
|
383
|
-
*
|
|
384
|
-
* The conflict sentence already matches byte-for-byte.
|
|
390
|
+
* `build_runtime_status` prints `str(exc)` as `Mirror home note:`. Until CR185
|
|
391
|
+
* the not-configured sentence was restated here in the oracle's shorter words;
|
|
392
|
+
* it is now the front door's own, the same on every surface, saying what to
|
|
393
|
+
* run and which file that writes.
|
|
385
394
|
*/
|
|
386
|
-
function mirrorHomeForStatus(
|
|
395
|
+
function mirrorHomeForStatus(
|
|
396
|
+
env: DbPathEnv,
|
|
397
|
+
osHome: string | undefined,
|
|
398
|
+
): { home: string | null; error: string | null } {
|
|
387
399
|
try {
|
|
388
|
-
|
|
400
|
+
const io = osHome === undefined ? {} : { home: osHome };
|
|
401
|
+
return { home: resolveMirrorHome(env, io), error: null };
|
|
389
402
|
} catch (error) {
|
|
390
|
-
|
|
391
|
-
if (message.startsWith("Mirror home is not configured")) {
|
|
392
|
-
return {
|
|
393
|
-
home: null,
|
|
394
|
-
error: "Mirror home is not configured. Set MIRROR_HOME or MIRROR_USER.",
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
return { home: null, error: message };
|
|
403
|
+
return { home: null, error: errorMessage(error) };
|
|
398
404
|
}
|
|
399
405
|
}
|
|
400
406
|
|
|
@@ -417,7 +423,7 @@ export function buildRuntimeStatus(options: BuildStatusOptions = {}): RuntimeSta
|
|
|
417
423
|
mirrorHome = existsSync(expanded) ? realpathSync(expanded) : expanded;
|
|
418
424
|
mirrorHomeError = null;
|
|
419
425
|
} else {
|
|
420
|
-
({ home: mirrorHome, error: mirrorHomeError } = mirrorHomeForStatus(env));
|
|
426
|
+
({ home: mirrorHome, error: mirrorHomeError } = mirrorHomeForStatus(env, options.osHome));
|
|
421
427
|
}
|
|
422
428
|
|
|
423
429
|
const memoryEnv = env.MEMORY_ENV || "production";
|
|
@@ -592,6 +598,10 @@ export function renderRuntimeStatus(
|
|
|
592
598
|
reasoningClause(env) +
|
|
593
599
|
`embedding=${env.MEMORY_EMBEDDING_MODEL ?? DEFAULT_EMBEDDING_MODEL}`,
|
|
594
600
|
);
|
|
601
|
+
// CR187: whether those models can be called at all. Presence only, by the
|
|
602
|
+
// predicate every refusal uses; never the value, and never the verdict -- the
|
|
603
|
+
// update gate reads the verdict, and a keyless install must still update.
|
|
604
|
+
lines.push(`OpenRouter key: ${hasOpenRouterKey(env) ? "configured" : "not configured"}`);
|
|
595
605
|
lines.push("");
|
|
596
606
|
lines.push(`Status: ${statusVerdict(report)}`);
|
|
597
607
|
return `${lines.join("\n")}\n`;
|
|
@@ -9,29 +9,19 @@
|
|
|
9
9
|
// decisions can be driven in tests without a remote, and so this module can be
|
|
10
10
|
// read as "what we ask git for" rather than "how we spawn it".
|
|
11
11
|
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
import {
|
|
13
|
+
describeChannelPosition,
|
|
14
|
+
GIT_NETWORK_TIMEOUT_MS,
|
|
15
|
+
type GitRunner,
|
|
16
|
+
readChannelPosition,
|
|
17
|
+
runGit,
|
|
18
|
+
splitUpstream,
|
|
19
|
+
} from "#runtime/git.ts";
|
|
20
|
+
import type { ApplyStrategy, PlanOutcome } from "#runtime/updatePipeline.ts";
|
|
21
21
|
|
|
22
22
|
export const defaultGitRunner: GitRunner = (args, cwd, timeoutMs) =>
|
|
23
23
|
runGit([...args], cwd, timeoutMs);
|
|
24
24
|
|
|
25
|
-
/** `origin/main` -> `["origin", "main"]`; anything else is refused. */
|
|
26
|
-
export function splitUpstream(upstream: string): [string, string] | null {
|
|
27
|
-
const index = upstream.indexOf("/");
|
|
28
|
-
if (index <= 0 || index === upstream.length - 1) return null;
|
|
29
|
-
const remote = upstream.slice(0, index);
|
|
30
|
-
const branch = upstream.slice(index + 1);
|
|
31
|
-
if (remote.startsWith("-") || branch.startsWith("-")) return null;
|
|
32
|
-
return [remote, branch];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
25
|
/** The commit this install is on, read BEFORE anything moves. */
|
|
36
26
|
export function captureCommit(repository: string, git: GitRunner): string | null {
|
|
37
27
|
const result = git(["rev-parse", "--short", "HEAD"], repository);
|
|
@@ -95,11 +85,72 @@ export function cloneRecoveryCommand(previous: string | null): string {
|
|
|
95
85
|
return previous === null ? "git reset --hard <previous commit>" : `git reset --hard ${previous}`;
|
|
96
86
|
}
|
|
97
87
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Where the checkout stands against `upstream`, as the update plan answers it.
|
|
90
|
+
* The position is the one `inspectGitUpdatePlan` reads, in the same words
|
|
91
|
+
* (CR186); what is added here is the way out of each refusal.
|
|
92
|
+
*/
|
|
93
|
+
function planFrom(
|
|
94
|
+
repository: string,
|
|
95
|
+
upstream: string,
|
|
96
|
+
previousRef: string,
|
|
97
|
+
git: GitRunner,
|
|
98
|
+
): PlanOutcome {
|
|
99
|
+
const position = readChannelPosition(repository, upstream, git);
|
|
100
|
+
const detail = describeChannelPosition(position);
|
|
101
|
+
const unchanged = "The working tree and the database are unchanged.";
|
|
102
|
+
switch (position.kind) {
|
|
103
|
+
case "unreadable":
|
|
104
|
+
return {
|
|
105
|
+
kind: "blocked",
|
|
106
|
+
detail: `cannot compare against ${upstream}`,
|
|
107
|
+
recovery: [`Resolve upstream tracking for ${upstream}, then retry runtime update.`],
|
|
108
|
+
};
|
|
109
|
+
case "current":
|
|
110
|
+
return { kind: "current", detail };
|
|
111
|
+
case "pull":
|
|
112
|
+
return {
|
|
113
|
+
kind: "ahead",
|
|
114
|
+
target: upstream,
|
|
115
|
+
detail,
|
|
116
|
+
dryRun: `would pull ${position.behind} commit(s) from ${upstream}`,
|
|
117
|
+
};
|
|
118
|
+
case "channel_behind":
|
|
119
|
+
// The package lane's refusal of a channel behind the installed version,
|
|
120
|
+
// in its words: the same state, whichever way Mirror was installed. No
|
|
121
|
+
// reset to the channel: the commits past it may already have migrated
|
|
122
|
+
// the database, and the channel's code refuses a database newer than it.
|
|
123
|
+
return {
|
|
124
|
+
kind: "blocked",
|
|
125
|
+
detail,
|
|
126
|
+
recovery: [
|
|
127
|
+
"A downgrade is refused: migrations do not run backwards.",
|
|
128
|
+
`Follow the channel that carries ${previousRef}: runtime channel ${position.carriedBy}`,
|
|
129
|
+
`Or stay on ${previousRef} until '${position.channel}' reaches it: the next release contains these commits.`,
|
|
130
|
+
unchanged,
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
case "local_commits":
|
|
134
|
+
return {
|
|
135
|
+
kind: "blocked",
|
|
136
|
+
detail,
|
|
137
|
+
recovery: [
|
|
138
|
+
"Local branch is ahead of upstream; push or reset before updating.",
|
|
139
|
+
unchanged,
|
|
140
|
+
`Current commit: ${previousRef}`,
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
case "diverged":
|
|
144
|
+
return {
|
|
145
|
+
kind: "blocked",
|
|
146
|
+
detail,
|
|
147
|
+
recovery: [
|
|
148
|
+
"Branch diverged; reconcile local and upstream commits manually.",
|
|
149
|
+
unchanged,
|
|
150
|
+
`Current commit: ${previousRef}`,
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
103
154
|
}
|
|
104
155
|
|
|
105
156
|
/**
|
|
@@ -151,37 +202,7 @@ export function cloneStrategy(repository: string, upstream: string, git: GitRunn
|
|
|
151
202
|
};
|
|
152
203
|
},
|
|
153
204
|
|
|
154
|
-
plan: (previousRef) =>
|
|
155
|
-
const ahead = countCommits(repository, `${upstream}..HEAD`, git);
|
|
156
|
-
const behind = countCommits(repository, `HEAD..${upstream}`, git);
|
|
157
|
-
if (ahead === null || behind === null) {
|
|
158
|
-
return {
|
|
159
|
-
kind: "blocked",
|
|
160
|
-
detail: `cannot compare against ${upstream}`,
|
|
161
|
-
recovery: [`Resolve upstream tracking for ${upstream}, then retry runtime update.`],
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
if (ahead === 0 && behind === 0) return { kind: "current", detail: "already up to date" };
|
|
165
|
-
if (ahead > 0) {
|
|
166
|
-
return {
|
|
167
|
-
kind: "blocked",
|
|
168
|
-
detail: behind > 0 ? "branch diverged" : "local commits present",
|
|
169
|
-
recovery: [
|
|
170
|
-
behind > 0
|
|
171
|
-
? "Branch diverged; reconcile local and upstream commits manually."
|
|
172
|
-
: "Local branch is ahead of upstream; push or reset before updating.",
|
|
173
|
-
"The working tree and the database are unchanged.",
|
|
174
|
-
`Current commit: ${previousRef}`,
|
|
175
|
-
],
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
return {
|
|
179
|
-
kind: "ahead",
|
|
180
|
-
target: upstream,
|
|
181
|
-
detail: `pull ${behind} remote commit(s)`,
|
|
182
|
-
dryRun: `would pull ${behind} commit(s) from ${upstream}`,
|
|
183
|
-
};
|
|
184
|
-
},
|
|
205
|
+
plan: (previousRef) => planFrom(repository, upstream, previousRef, git),
|
|
185
206
|
|
|
186
207
|
apply: (previousRef, target) => {
|
|
187
208
|
const applied = fastForward(repository, target, git);
|
package/ts/src/runtime/update.ts
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { spawnSync } from "node:child_process";
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
import { verifyBackupArchive } from "#runtime/backup.ts";
|
|
31
|
+
import type { GitRunner } from "#runtime/git.ts";
|
|
31
32
|
import type { InstallKind } from "#runtime/installKind.ts";
|
|
32
33
|
import {
|
|
33
34
|
agyPluginCopyState,
|
|
@@ -35,7 +36,7 @@ import {
|
|
|
35
36
|
grokPluginCopyState,
|
|
36
37
|
installedPluginVersion,
|
|
37
38
|
} from "#runtime/pluginWiring.ts";
|
|
38
|
-
import { cloneStrategy, defaultGitRunner
|
|
39
|
+
import { cloneStrategy, defaultGitRunner } from "#runtime/strategies/clone.ts";
|
|
39
40
|
import { defaultNpmRunner, type NpmRunner, packageStrategy } from "#runtime/strategies/package.ts";
|
|
40
41
|
import type { statusAllowsUpdatePreflight } from "#runtime/updateGate.ts";
|
|
41
42
|
import {
|
|
@@ -350,8 +350,10 @@ function classifyDegradedCause(error: unknown): string {
|
|
|
350
350
|
if (error instanceof ProviderConfigError) return "config";
|
|
351
351
|
if (error instanceof LlmTransportError) return error.kind;
|
|
352
352
|
if (error instanceof EmbeddingError) {
|
|
353
|
-
// generateEmbeddingSafely wraps a provider exception
|
|
354
|
-
//
|
|
353
|
+
// generateEmbeddingSafely wraps a provider exception. Since CR187 the
|
|
354
|
+
// wrapper keeps a transport failure's kind, so a refused key reads `auth`;
|
|
355
|
+
// anything else is still answered by the layer that failed.
|
|
356
|
+
if (error.transport !== null) return error.transport.kind;
|
|
355
357
|
return error.permanent ? "embedding_permanent" : "embedding_exhausted";
|
|
356
358
|
}
|
|
357
359
|
return "unknown";
|