@avantf/dsh-plugin-base 0.5.2 → 0.6.0
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/README.md +133 -20
- package/{dist → lib}/bootstrap.d.ts +1 -1
- package/{dist → lib}/bootstrap.d.ts.map +1 -1
- package/{dist → lib}/bootstrap.js +45 -8
- package/{dist → lib}/compat.d.ts +10 -0
- package/{dist → lib}/compat.d.ts.map +1 -1
- package/{dist → lib}/compat.js +30 -7
- package/{dist → lib}/conformance.d.ts.map +1 -1
- package/{dist → lib}/conformance.js +5 -0
- package/{dist → lib}/errors.d.ts +3 -1
- package/lib/errors.d.ts.map +1 -0
- package/{dist → lib}/fs.d.ts.map +1 -1
- package/{dist → lib}/fs.js +3 -0
- package/{dist → lib}/index.d.ts +10 -4
- package/lib/index.d.ts.map +1 -0
- package/{dist → lib}/index.js +18 -3
- package/{dist → lib}/interface.d.ts +165 -32
- package/lib/interface.d.ts.map +1 -0
- package/{dist → lib}/interface.js +56 -16
- package/{dist → lib}/internal.d.ts +2 -0
- package/{dist → lib}/internal.d.ts.map +1 -1
- package/{dist → lib}/internal.js +3 -0
- package/{dist → lib}/lint.d.ts.map +1 -1
- package/{dist → lib}/lint.js +42 -2
- package/{dist → lib}/providers/model.d.ts.map +1 -1
- package/{dist → lib}/providers/model.js +100 -17
- package/{dist → lib}/providers/npm.d.ts +10 -1
- package/lib/providers/npm.d.ts.map +1 -0
- package/{dist → lib}/providers/npm.js +6 -2
- package/lib/providers/runtime_deps.d.ts +184 -0
- package/lib/providers/runtime_deps.d.ts.map +1 -0
- package/lib/providers/runtime_deps.js +1214 -0
- package/{dist → lib}/provisioner.d.ts.map +1 -1
- package/{dist → lib}/provisioner.js +55 -33
- package/lib/runtime_deps_helper.d.ts +71 -0
- package/lib/runtime_deps_helper.d.ts.map +1 -0
- package/lib/runtime_deps_helper.js +161 -0
- package/lib/session_notices.d.ts +95 -0
- package/lib/session_notices.d.ts.map +1 -0
- package/lib/session_notices.js +172 -0
- package/{dist → lib}/state.d.ts +8 -1
- package/{dist → lib}/state.d.ts.map +1 -1
- package/{dist → lib}/state.js +20 -4
- package/{dist → lib}/types.d.ts +14 -1
- package/lib/types.d.ts.map +1 -0
- package/package.json +37 -33
- package/dist/cli.d.ts +0 -4
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -37
- package/dist/errors.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/interface.d.ts.map +0 -1
- package/dist/providers/npm.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
- /package/{dist → lib}/artifact.d.ts +0 -0
- /package/{dist → lib}/artifact.d.ts.map +0 -0
- /package/{dist → lib}/artifact.js +0 -0
- /package/{dist → lib}/conformance.d.ts +0 -0
- /package/{dist → lib}/errors.js +0 -0
- /package/{dist → lib}/fs.d.ts +0 -0
- /package/{dist → lib}/integrity.d.ts +0 -0
- /package/{dist → lib}/integrity.d.ts.map +0 -0
- /package/{dist → lib}/integrity.js +0 -0
- /package/{dist → lib}/interface_gate.d.ts +0 -0
- /package/{dist → lib}/interface_gate.d.ts.map +0 -0
- /package/{dist → lib}/interface_gate.js +0 -0
- /package/{dist → lib}/kit/family.d.ts +0 -0
- /package/{dist → lib}/kit/family.d.ts.map +0 -0
- /package/{dist → lib}/kit/family.js +0 -0
- /package/{dist → lib}/kit/index.d.ts +0 -0
- /package/{dist → lib}/kit/index.d.ts.map +0 -0
- /package/{dist → lib}/kit/index.js +0 -0
- /package/{dist → lib}/kit/logger.d.ts +0 -0
- /package/{dist → lib}/kit/logger.d.ts.map +0 -0
- /package/{dist → lib}/kit/logger.js +0 -0
- /package/{dist → lib}/kit/prompt_files.d.ts +0 -0
- /package/{dist → lib}/kit/prompt_files.d.ts.map +0 -0
- /package/{dist → lib}/kit/prompt_files.js +0 -0
- /package/{dist → lib}/kit/typert.d.ts +0 -0
- /package/{dist → lib}/kit/typert.d.ts.map +0 -0
- /package/{dist → lib}/kit/typert.js +0 -0
- /package/{dist → lib}/kit/wellformed.d.ts +0 -0
- /package/{dist → lib}/kit/wellformed.d.ts.map +0 -0
- /package/{dist → lib}/kit/wellformed.js +0 -0
- /package/{dist → lib}/layout.d.ts +0 -0
- /package/{dist → lib}/layout.d.ts.map +0 -0
- /package/{dist → lib}/layout.js +0 -0
- /package/{dist → lib}/lint.d.ts +0 -0
- /package/{dist → lib}/lock.d.ts +0 -0
- /package/{dist → lib}/lock.d.ts.map +0 -0
- /package/{dist → lib}/lock.js +0 -0
- /package/{dist → lib}/manifest.d.ts +0 -0
- /package/{dist → lib}/manifest.d.ts.map +0 -0
- /package/{dist → lib}/manifest.js +0 -0
- /package/{dist → lib}/net.d.ts +0 -0
- /package/{dist → lib}/net.d.ts.map +0 -0
- /package/{dist → lib}/net.js +0 -0
- /package/{dist → lib}/package-name.d.ts +0 -0
- /package/{dist → lib}/package-name.d.ts.map +0 -0
- /package/{dist → lib}/package-name.js +0 -0
- /package/{dist → lib}/preset.d.ts +0 -0
- /package/{dist → lib}/preset.d.ts.map +0 -0
- /package/{dist → lib}/preset.js +0 -0
- /package/{dist → lib}/providers/archive.d.ts +0 -0
- /package/{dist → lib}/providers/archive.d.ts.map +0 -0
- /package/{dist → lib}/providers/archive.js +0 -0
- /package/{dist → lib}/providers/model.d.ts +0 -0
- /package/{dist → lib}/provisioner.d.ts +0 -0
- /package/{dist → lib}/semver.d.ts +0 -0
- /package/{dist → lib}/semver.d.ts.map +0 -0
- /package/{dist → lib}/semver.js +0 -0
- /package/{dist → lib}/tar.d.ts +0 -0
- /package/{dist → lib}/tar.d.ts.map +0 -0
- /package/{dist → lib}/tar.js +0 -0
- /package/{dist → lib}/types.js +0 -0
- /package/{dist → lib}/zip.d.ts +0 -0
- /package/{dist → lib}/zip.d.ts.map +0 -0
- /package/{dist → lib}/zip.js +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"provisioner.d.ts","sourceRoot":"","sources":["../src/provisioner.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EACV,SAAS,EACT,KAAK,EAeL,aAAa,EAMb,WAAW,EAEX,kBAAkB,
|
|
1
|
+
{"version":3,"file":"provisioner.d.ts","sourceRoot":"","sources":["../src/provisioner.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EACV,SAAS,EACT,KAAK,EAeL,aAAa,EAMb,WAAW,EAEX,kBAAkB,EAOnB,MAAM,YAAY,CAAA;AAEnB,yDAAyD;AACzD,eAAO,MAAM,YAAY,EAAE,SAAS,MAAM,EAA6C,CAAA;AAEvF,kDAAkD;AAClD,eAAO,MAAM,mBAAmB,QAAS,CAAA;AAEzC,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAepC,UAAU,iBAAiB;IACzB,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAA;IAC7B,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;CACtB;AAED;;;iFAGiF;AACjF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,aAAa,CAAC,WAAW,CAAC,GAAG,iBAAiB,CAKvF;AA0DD;cACc;AACd,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,WAAW,CAy/B1E"}
|
|
@@ -200,38 +200,48 @@ export function createProvisioner(options) {
|
|
|
200
200
|
const listeners = new Set();
|
|
201
201
|
/** Layout read-only state, decided once per process. */
|
|
202
202
|
let readOnly;
|
|
203
|
-
|
|
203
|
+
/**
|
|
204
|
+
* The in-flight (then settled) home verdict. Every caller awaits the SAME promise, so a
|
|
205
|
+
* concurrent `declare`/`ensure` cannot slip past the gate while `readLayout` is still deciding:
|
|
206
|
+
* the old boolean was set BEFORE the await, so a second caller returned early with `readOnly`
|
|
207
|
+
* still unset and `ensureOne` then wrote into a too-new/mismatched home. The promise is never
|
|
208
|
+
* re-created — the verdict is once-per-process, and a rejection is part of the verdict: the body
|
|
209
|
+
* already folds ordinary IO failures into a `layout/unreadable` read-only code, so a promise
|
|
210
|
+
* that still rejects is not something a silent retry is expected to fix, and later callers
|
|
211
|
+
* observe the same failure rather than racing the home again.
|
|
212
|
+
*/
|
|
213
|
+
let homePromise;
|
|
204
214
|
const pendingDeclared = [];
|
|
205
215
|
/** Decide the home's layout once. */
|
|
206
|
-
const checkHome =
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
unsupportedWarned = true;
|
|
212
|
-
logger.warn(`unsupported-envinit: 本副本 ${FRAMEWORK_VERSION} 不满足插件声明的区间 "${String(options.envinitRange)}";跳过该清单、降级挂载`);
|
|
213
|
-
}
|
|
214
|
-
try {
|
|
215
|
-
const result = await readLayout(fs, home, layout);
|
|
216
|
-
if (result.kind === 'none') {
|
|
217
|
-
await writeLayout(fs, home, layout, 'dsh-plugin-base');
|
|
216
|
+
const checkHome = () => {
|
|
217
|
+
homePromise ??= (async () => {
|
|
218
|
+
if (unsupported !== undefined && !unsupportedWarned) {
|
|
219
|
+
unsupportedWarned = true;
|
|
220
|
+
logger.warn(`unsupported-envinit: 本副本 ${FRAMEWORK_VERSION} 不满足插件声明的区间 "${String(options.envinitRange)}";跳过该清单、降级挂载`);
|
|
218
221
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
+
try {
|
|
223
|
+
const result = await readLayout(fs, home, layout);
|
|
224
|
+
if (result.kind === 'none') {
|
|
225
|
+
await writeLayout(fs, home, layout, 'dsh-plugin-base');
|
|
226
|
+
}
|
|
227
|
+
else if (result.kind === 'too-new') {
|
|
228
|
+
readOnly = 'layout/too-new';
|
|
229
|
+
logger.warn(`layout/too-new: ${home} 由更新的布局写入;本次只读不写`);
|
|
230
|
+
}
|
|
231
|
+
else if (result.kind === 'mismatch') {
|
|
232
|
+
readOnly = 'layout/mismatch';
|
|
233
|
+
logger.warn(`layout/mismatch: ${home} 的布局是 "${result.file.layout}",本次是 "${layout}";本次只读不写`);
|
|
234
|
+
}
|
|
222
235
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
236
|
+
catch (error) {
|
|
237
|
+
// A read failure keeps the read-only CONSEQUENCE but gets its own code: every item's report
|
|
238
|
+
// would otherwise say `layout/too-new` — "a NEWER layout wrote this, leave it alone" — about a
|
|
239
|
+
// home whose layout nobody managed to read at all.
|
|
240
|
+
logger.warn(`layout: 无法读写 .layout.json(${reasonOf(error).message});按只读继续`);
|
|
241
|
+
readOnly = 'layout/unreadable';
|
|
226
242
|
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// A read failure keeps the read-only CONSEQUENCE but gets its own code: every item's report
|
|
230
|
-
// would otherwise say `layout/too-new` — "a NEWER layout wrote this, leave it alone" — about a
|
|
231
|
-
// home whose layout nobody managed to read at all.
|
|
232
|
-
logger.warn(`layout: 无法读写 .layout.json(${reasonOf(error).message});按只读继续`);
|
|
233
|
-
readOnly = 'layout/unreadable';
|
|
234
|
-
}
|
|
243
|
+
})();
|
|
244
|
+
return homePromise;
|
|
235
245
|
};
|
|
236
246
|
/**
|
|
237
247
|
* The failure a persisted row should carry: an explicit `lastError` if something ever sets one,
|
|
@@ -687,6 +697,8 @@ export function createProvisioner(options) {
|
|
|
687
697
|
return settle(declared, started, 'skipped', 'policy/offline');
|
|
688
698
|
const staging = stagingDir(home, item.id, (stagingSequence += 1));
|
|
689
699
|
let published;
|
|
700
|
+
/** Set only when the core's own `publish()` placed the directory — see the catch below. */
|
|
701
|
+
let corePublished;
|
|
690
702
|
const installCtx = {
|
|
691
703
|
...ctx,
|
|
692
704
|
stage: async () => {
|
|
@@ -696,6 +708,7 @@ export function createProvisioner(options) {
|
|
|
696
708
|
publish: async (dir, meta) => {
|
|
697
709
|
const resolved = await publish(declared, dir, meta);
|
|
698
710
|
published = resolved;
|
|
711
|
+
corePublished = resolved;
|
|
699
712
|
return resolved;
|
|
700
713
|
},
|
|
701
714
|
...(onProgress === undefined
|
|
@@ -731,18 +744,27 @@ export function createProvisioner(options) {
|
|
|
731
744
|
},
|
|
732
745
|
};
|
|
733
746
|
putRow(declared, resolved.version, state, resolved.source);
|
|
734
|
-
|
|
747
|
+
// A provider that says it only REUSED an equivalent root (another process published it while
|
|
748
|
+
// this one waited) placed nothing: the honest action is `present`, not `installed`.
|
|
749
|
+
const action = resolved.reused === true ? 'present' : 'installed';
|
|
750
|
+
return entryOf(declared, { action, source: resolved.source, started, version: resolved.version });
|
|
735
751
|
}
|
|
736
752
|
catch (error) {
|
|
737
753
|
const { code, message } = reasonOf(error);
|
|
738
|
-
// A published-but-unusable directory is quarantined, never deleted
|
|
739
|
-
|
|
754
|
+
// A published-but-unusable directory is quarantined, never deleted — but ONLY a directory the
|
|
755
|
+
// core itself published through `ctx.publish()`. A provider that bypasses `publish()`
|
|
756
|
+
// (`model-cache`, whose `dir` is the shared `snapshots/<sha>` that `refs/<revision>` and every
|
|
757
|
+
// sibling item of the same repo+sha point at) manages its own storage: renaming that directory
|
|
758
|
+
// here would dangle the ref and take healthy sibling items down with the failed one. Such a
|
|
759
|
+
// provider withdraws its own completion marker on verify failure instead (see
|
|
760
|
+
// `providers/model.ts`), which keeps the next `probe` honest without touching shared state.
|
|
761
|
+
if (corePublished !== undefined) {
|
|
740
762
|
try {
|
|
741
|
-
await quarantineLocked(
|
|
742
|
-
logger.warn(`quarantined ${
|
|
763
|
+
await quarantineLocked(corePublished.dir);
|
|
764
|
+
logger.warn(`quarantined ${corePublished.dir}: ${message}`);
|
|
743
765
|
}
|
|
744
766
|
catch (renameError) {
|
|
745
|
-
logger.warn(`could not quarantine ${
|
|
767
|
+
logger.warn(`could not quarantine ${corePublished.dir}: ${reasonOf(renameError).message}`);
|
|
746
768
|
}
|
|
747
769
|
}
|
|
748
770
|
const state = { state: 'failed', code, detail: message };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { DependencySource, InstallAttempt } from './providers/runtime_deps.js';
|
|
2
|
+
import type { SessionNoticeRegistry, SessionNoticeSpec } from './session_notices.js';
|
|
3
|
+
import type { ProvisionItem, ProvisionReportEntry, Provisioner, Resolved } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* What a plugin declares. A superset of {@link RuntimeDepsSpec} (the provider's own input) plus the
|
|
6
|
+
* identity and the notices: the spec fields are forwarded verbatim, so nothing here decides anything
|
|
7
|
+
* about packages, registries or managers.
|
|
8
|
+
*/
|
|
9
|
+
export interface RuntimeDepsConfig {
|
|
10
|
+
/** The plugin's namespace (`mem`); the default item id is `${namespace}:runtime-deps`. */
|
|
11
|
+
readonly namespace: string;
|
|
12
|
+
/** Override the item id; must still be prefixed with `${namespace}:`. */
|
|
13
|
+
readonly itemId?: string;
|
|
14
|
+
/** Package name → dependency spec. Forwarded to {@link RuntimeDepsSpec.packages}. */
|
|
15
|
+
readonly packages: Readonly<Record<string, string>>;
|
|
16
|
+
/** The registry/cache endpoints the attempts reference. */
|
|
17
|
+
readonly sources: readonly DependencySource[];
|
|
18
|
+
/** Install attempts, tried in order. */
|
|
19
|
+
readonly attempts: readonly InstallAttempt[];
|
|
20
|
+
/** Specifiers every successful root must resolve; non-empty. */
|
|
21
|
+
readonly verify: readonly string[];
|
|
22
|
+
/** Host-provided singletons, linked never installed. */
|
|
23
|
+
readonly peers?: Readonly<Record<string, string>>;
|
|
24
|
+
/** A lockfile whose bytes seed/audit the install. */
|
|
25
|
+
readonly lockfile?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Notices to deliver once per session when this item is NOT ready. Each one's `when` selects which
|
|
28
|
+
* observed states match: `failed` → the item failed; `missing` → it is missing or was skipped;
|
|
29
|
+
* `degraded` → any of those three (the broad "the shared root is not there" condition).
|
|
30
|
+
*/
|
|
31
|
+
readonly notices?: readonly SessionNoticeSpec[];
|
|
32
|
+
}
|
|
33
|
+
/** What {@link createRuntimeDeps} hands back. */
|
|
34
|
+
export interface RuntimeDepsHandle {
|
|
35
|
+
/** The item to declare through the caller's own provisioner. */
|
|
36
|
+
readonly item: ProvisionItem;
|
|
37
|
+
/** The registry, pre-loaded with {@link RuntimeDepsConfig.notices}. */
|
|
38
|
+
readonly notices: SessionNoticeRegistry;
|
|
39
|
+
/**
|
|
40
|
+
* Ensure THIS item through the caller's provisioner and return its report entry.
|
|
41
|
+
*
|
|
42
|
+
* The item must already be declared (the helper never declares); `undefined` means the report
|
|
43
|
+
* carried no entry for it — a background item is dispatched and reports through `onSettled`
|
|
44
|
+
* instead, and `resolve()` reflects it as soon as it settles. A rejected `ensure` propagates: that
|
|
45
|
+
* is a dispatch failure, not a provisioning outcome.
|
|
46
|
+
*/
|
|
47
|
+
ensure(provisioner: Provisioner): Promise<ProvisionReportEntry | undefined>;
|
|
48
|
+
/** The ready shared root as the core reports it, or `undefined` while it is not ready. */
|
|
49
|
+
resolve(): Resolved | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Resolve `specifier` from the shared root and import its entry (dynamic `import()`).
|
|
52
|
+
*
|
|
53
|
+
* Read-only by construction: `createRequire(<root>/package.json).resolve()` plus `import()` of the
|
|
54
|
+
* resolved file — no copy, no `node_modules` write, no link. The module is imported by FILE URL, so
|
|
55
|
+
* a package that must not be resolved twice through the host's tree still gets this root's copy.
|
|
56
|
+
*
|
|
57
|
+
* Rejects with a {@link ProvisionError} (`invalid-option`) when the root is not ready or the
|
|
58
|
+
* specifier is empty, and with the underlying resolution/import error otherwise — a failed resolve
|
|
59
|
+
* is the caller's specifier meeting this root, not a provisioning failure with a code.
|
|
60
|
+
*/
|
|
61
|
+
load<T = unknown>(specifier: string): Promise<T>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Build the handle for one shared runtime-dependency root.
|
|
65
|
+
*
|
|
66
|
+
* @param config - the plugin's declaration; forwarded to the provider field by field.
|
|
67
|
+
* @throws ProvisionError (`invalid-option`) for a namespace/itemId that cannot make a namespaced item
|
|
68
|
+
* id — a caller bug at construction time, not a provisioning outcome.
|
|
69
|
+
*/
|
|
70
|
+
export declare function createRuntimeDeps(config: RuntimeDepsConfig): RuntimeDepsHandle;
|
|
71
|
+
//# sourceMappingURL=runtime_deps_helper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime_deps_helper.d.ts","sourceRoot":"","sources":["../src/runtime_deps_helper.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAmB,MAAM,6BAA6B,CAAA;AACpG,OAAO,KAAK,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAA;AACpF,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,WAAW,EAAE,QAAQ,EAAiB,MAAM,YAAY,CAAA;AAE3G;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,0FAA0F;IAC1F,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,yEAAyE;IACzE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,qFAAqF;IACrF,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACnD,2DAA2D;IAC3D,QAAQ,CAAC,OAAO,EAAE,SAAS,gBAAgB,EAAE,CAAA;IAC7C,wCAAwC;IACxC,QAAQ,CAAC,QAAQ,EAAE,SAAS,cAAc,EAAE,CAAA;IAC5C,gEAAgE;IAChE,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;IAClC,wDAAwD;IACxD,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACjD,qDAAqD;IACrD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,iBAAiB,EAAE,CAAA;CAChD;AAED,iDAAiD;AACjD,MAAM,WAAW,iBAAiB;IAChC,gEAAgE;IAChE,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAA;IAC5B,uEAAuE;IACvE,QAAQ,CAAC,OAAO,EAAE,qBAAqB,CAAA;IACvC;;;;;;;OAOG;IACH,MAAM,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC,CAAA;IAC3E,0FAA0F;IAC1F,OAAO,IAAI,QAAQ,GAAG,SAAS,CAAA;IAC/B;;;;;;;;;;OAUG;IACH,IAAI,CAAC,CAAC,GAAG,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAA;CACjD;AAiDD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,iBAAiB,GAAG,iBAAiB,CA+E9E"}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createRuntimeDeps` — the thin, library-level helper a plugin uses to DECLARE one shared
|
|
3
|
+
* runtime-dependency root and then reach it: the item, the session notices and the loader.
|
|
4
|
+
*
|
|
5
|
+
* It is sugar over two members this package already exports, never a second implementation of them:
|
|
6
|
+
*
|
|
7
|
+
* - `runtimeDepsProvider()` (interface v5) owns the content-addressed store, the install attempts,
|
|
8
|
+
* verify, peer linking and failure reporting;
|
|
9
|
+
* - `createSessionNotices()` (interface v6) owns the once-per-session notice bookkeeping.
|
|
10
|
+
*
|
|
11
|
+
* What the helper adds is the wiring in between, and nothing else:
|
|
12
|
+
*
|
|
13
|
+
* - **one item** built from the caller's spec — `kind: 'runtime-deps'`, the canonical
|
|
14
|
+
* `<home>/deps/runtime` store, this build's item schema. Base names no package;
|
|
15
|
+
* - **notices bound to the ITEM's real state**: each {@link SessionNoticeSpec.when} is mapped onto the
|
|
16
|
+
* provisioner's own `resolve(item.id)`, re-read on every `consume` — so a root that healed stops
|
|
17
|
+
* matching, exactly as the registry requires;
|
|
18
|
+
* - **`ensure()` / `resolve()`** as the caller's handle onto the provisioner it already owns (the
|
|
19
|
+
* plugin still declares the item and still calls `ensure`; the helper only narrows the answer to
|
|
20
|
+
* this item);
|
|
21
|
+
* - **`load()`**, which resolves a specifier from the shared root and imports it — it COPIES NO
|
|
22
|
+
* PACKAGE and writes nothing, including no `node_modules`.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately absent: no hook registration, no configuration reading, no filesystem access at
|
|
25
|
+
* construction, no session bookkeeping of its own. Base stays a library, and the plugin stays the only
|
|
26
|
+
* DSH lifecycle owner.
|
|
27
|
+
*
|
|
28
|
+
* @module runtime_deps_helper
|
|
29
|
+
*/
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import { pathToFileURL } from 'node:url';
|
|
33
|
+
import { ProvisionError } from './errors.js';
|
|
34
|
+
import { ITEM_SCHEMA_VERSION } from './provisioner.js';
|
|
35
|
+
import { RUNTIME_DEPS_DIR, RUNTIME_DEPS_KIND } from './providers/runtime_deps.js';
|
|
36
|
+
import { createSessionNotices } from './session_notices.js';
|
|
37
|
+
/** `plugin` namespaces this build accepts in an item id: anything but `:`, `/` and whitespace. */
|
|
38
|
+
const NAMESPACE = /^[^\s:/]+$/u;
|
|
39
|
+
/** The observed states one {@link SessionNoticeSpec.when} covers. */
|
|
40
|
+
function matchesWhen(when, state) {
|
|
41
|
+
if (state === undefined)
|
|
42
|
+
return false;
|
|
43
|
+
switch (state.state) {
|
|
44
|
+
case 'failed':
|
|
45
|
+
return when === 'failed' || when === 'degraded';
|
|
46
|
+
case 'missing':
|
|
47
|
+
case 'skipped':
|
|
48
|
+
return when === 'missing' || when === 'degraded';
|
|
49
|
+
// `pending` is deliberately NOT a notice: the install/job is still in flight, and a latch taken
|
|
50
|
+
// while it runs would make a "not ready" line permanent for that session even after it succeeds.
|
|
51
|
+
case 'pending':
|
|
52
|
+
case 'ready':
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The `Resolved` shape of a ready handle.
|
|
58
|
+
*
|
|
59
|
+
* The fields come from the CORE's {@link ResourceHandle}; `entryDir` equals `dir` because the shared
|
|
60
|
+
* store publishes its root as the usable entry (the provider's own `resolvedOf` says the same), and
|
|
61
|
+
* `name` is the item id the caller declared. Nothing is invented: a handle that is not ready has no
|
|
62
|
+
* `Resolved` at all.
|
|
63
|
+
*/
|
|
64
|
+
function resolvedOf(state) {
|
|
65
|
+
if (state?.state !== 'ready')
|
|
66
|
+
return undefined;
|
|
67
|
+
const handle = state.handle;
|
|
68
|
+
return {
|
|
69
|
+
key: handle.key,
|
|
70
|
+
name: handle.id,
|
|
71
|
+
version: handle.version ?? '',
|
|
72
|
+
dir: handle.dir,
|
|
73
|
+
entryDir: handle.dir,
|
|
74
|
+
source: handle.source,
|
|
75
|
+
...(Object.keys(handle.env).length === 0 ? {} : { env: handle.env }),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** A declared list, or an empty one: a missing/malformed field is the provider's `invalid-option`. */
|
|
79
|
+
function arrayOf(value) {
|
|
80
|
+
return Array.isArray(value) ? value : [];
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build the handle for one shared runtime-dependency root.
|
|
84
|
+
*
|
|
85
|
+
* @param config - the plugin's declaration; forwarded to the provider field by field.
|
|
86
|
+
* @throws ProvisionError (`invalid-option`) for a namespace/itemId that cannot make a namespaced item
|
|
87
|
+
* id — a caller bug at construction time, not a provisioning outcome.
|
|
88
|
+
*/
|
|
89
|
+
export function createRuntimeDeps(config) {
|
|
90
|
+
const namespace = config.namespace;
|
|
91
|
+
if (typeof namespace !== 'string' || !NAMESPACE.test(namespace)) {
|
|
92
|
+
throw new ProvisionError('invalid-option', `runtime-deps 的 namespace 必须是插件名(不含 :、/ 与空白):${JSON.stringify(namespace)}`);
|
|
93
|
+
}
|
|
94
|
+
const id = config.itemId ?? `${namespace}:runtime-deps`;
|
|
95
|
+
if (typeof id !== 'string' || !id.startsWith(`${namespace}:`) || id.length <= namespace.length + 1) {
|
|
96
|
+
throw new ProvisionError('invalid-option', `runtime-deps 的 itemId 必须以 "${namespace}:" 前缀化且非空:${JSON.stringify(config.itemId)}`);
|
|
97
|
+
}
|
|
98
|
+
// COPIED, field by field, for two reasons: the caller may keep mutating its own config object (the
|
|
99
|
+
// spec is content-key material, so it must be frozen at construction), and a missing field becomes an
|
|
100
|
+
// empty one, which the PROVIDER then rejects as `invalid-option` on the item — a bad declaration
|
|
101
|
+
// degrades to a failed item instead of throwing out of the plugin's mount path.
|
|
102
|
+
const spec = {
|
|
103
|
+
packages: { ...config.packages },
|
|
104
|
+
sources: arrayOf(config.sources).map(source => ({ ...source })),
|
|
105
|
+
attempts: arrayOf(config.attempts).map(attempt => ({ ...attempt })),
|
|
106
|
+
verify: [...arrayOf(config.verify)],
|
|
107
|
+
...(config.peers === undefined ? {} : { peers: { ...config.peers } }),
|
|
108
|
+
...(config.lockfile === undefined ? {} : { lockfile: config.lockfile }),
|
|
109
|
+
};
|
|
110
|
+
const item = {
|
|
111
|
+
id,
|
|
112
|
+
kind: RUNTIME_DEPS_KIND,
|
|
113
|
+
spec,
|
|
114
|
+
// The provider's canonical shared store: every plugin declaring the same spec meets in ONE root.
|
|
115
|
+
target: { root: RUNTIME_DEPS_DIR },
|
|
116
|
+
schemaVersion: ITEM_SCHEMA_VERSION,
|
|
117
|
+
};
|
|
118
|
+
/** The provisioner handed to {@link RuntimeDepsHandle.ensure}; notices need it to observe state. */
|
|
119
|
+
let active;
|
|
120
|
+
const stateNow = () => {
|
|
121
|
+
if (active === undefined)
|
|
122
|
+
return undefined;
|
|
123
|
+
try {
|
|
124
|
+
return active.resolve(id);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// `resolve` is a map read and does not throw; a hostile/partial provisioner must not turn a
|
|
128
|
+
// prompt line into a crash either way.
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const resolveNow = () => resolvedOf(stateNow());
|
|
133
|
+
const notices = createSessionNotices();
|
|
134
|
+
for (const notice of arrayOf(config.notices)) {
|
|
135
|
+
notices.register(notice, () => matchesWhen(notice.when, stateNow()));
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
item,
|
|
139
|
+
notices,
|
|
140
|
+
async ensure(provisioner) {
|
|
141
|
+
active = provisioner;
|
|
142
|
+
const report = await provisioner.ensure({ only: [id] });
|
|
143
|
+
return report.entries.find(entry => entry.id === id);
|
|
144
|
+
},
|
|
145
|
+
resolve: resolveNow,
|
|
146
|
+
async load(specifier) {
|
|
147
|
+
const resolved = resolveNow();
|
|
148
|
+
if (resolved === undefined) {
|
|
149
|
+
throw new ProvisionError('invalid-option', `runtime-deps 句柄尚未就绪:item ${id} 没有共享根(先 ensure,并在 settle 之后再 load)`);
|
|
150
|
+
}
|
|
151
|
+
if (typeof specifier !== 'string' || specifier.trim() === '') {
|
|
152
|
+
throw new ProvisionError('invalid-option', `runtime-deps.load 需要一个非空的 specifier(item ${id})`);
|
|
153
|
+
}
|
|
154
|
+
// The root's own `package.json` is the resolution anchor: `node_modules` under it is what the
|
|
155
|
+
// provider installed, and it is the ONLY tree this import may see.
|
|
156
|
+
const entry = createRequire(join(resolved.dir, 'package.json')).resolve(specifier);
|
|
157
|
+
return (await import(pathToFileURL(entry).href));
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=runtime_deps_helper.js.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped notices — the generic, in-memory channel through which a plugin tells ONE agent
|
|
3
|
+
* session that one of its optional capabilities is not ready.
|
|
4
|
+
*
|
|
5
|
+
* The defect this exists for is a shape, not a package: a plugin provisions an optional capability
|
|
6
|
+
* (a shared runtime-dependency root, a model cache, an archive) in the BACKGROUND, mounts either way,
|
|
7
|
+
* and then has to say so in the owner's prompt — exactly once, with the caller's own wording, and
|
|
8
|
+
* without ever being able to refuse the mount or to leak a stale line after the capability healed.
|
|
9
|
+
* Every family plugin re-implemented that bookkeeping locally; this is the ONE implementation, so a
|
|
10
|
+
* fix here is a base release and not one rebuild per plugin.
|
|
11
|
+
*
|
|
12
|
+
* The registry deliberately knows nothing else:
|
|
13
|
+
*
|
|
14
|
+
* - **No wording.** {@link SessionNoticeSpec.body} is returned VERBATIM; the registry adds no prefix,
|
|
15
|
+
* no severity label and no punctuation. Base names no package, command or user-facing sentence.
|
|
16
|
+
* - **No state of its own.** `state()` is the caller's predicate and it is evaluated on EVERY
|
|
17
|
+
* {@link SessionNoticeRegistry.consume}: a healed dependency simply stops matching, without the
|
|
18
|
+
* registry having to know what "healed" means.
|
|
19
|
+
* - **No session identity.** The caller supplies the session id and decides which sessions may see a
|
|
20
|
+
* line ({@link SessionNoticeRegistry.consume}'s `ownerOnly`); the registry only remembers, per
|
|
21
|
+
* session id, which notice ids it has already delivered.
|
|
22
|
+
* - **No persistence.** Session ids live in a `Map` for the lifetime of the instance. Nothing here
|
|
23
|
+
* touches the filesystem, a database, `process.env` or any global: a notice is a prompt line, not
|
|
24
|
+
* a record.
|
|
25
|
+
* - **No throwing.** `consume` runs on the prompt path of a live agent; a caller bug or a throwing
|
|
26
|
+
* `state()` must cost one skipped line, never the turn.
|
|
27
|
+
*
|
|
28
|
+
* The registry is a LIBRARY, not a DSH plugin: it registers no hook, mounts nothing and reads no
|
|
29
|
+
* configuration. The caller owns the lifecycle (which hook, which session, which text) and the
|
|
30
|
+
* registry owns only the once-per-session bookkeeping.
|
|
31
|
+
*
|
|
32
|
+
* @module session_notices
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* One notice a caller may want shown.
|
|
36
|
+
*
|
|
37
|
+
* `id` is the notice's identity for the once-per-session latch, so it must be namespaced by the
|
|
38
|
+
* plugin's own item/plugin prefix (`<plugin>:…`): two plugins sharing a process must not be able to
|
|
39
|
+
* silently suppress each other's line by picking the same id.
|
|
40
|
+
*/
|
|
41
|
+
export interface SessionNoticeSpec {
|
|
42
|
+
/** Stable id, namespaced `<plugin>:…`; the unit the once-per-session rule applies to. */
|
|
43
|
+
readonly id: string;
|
|
44
|
+
/** How loud the line is. Ordering only — the registry renders no label for it. */
|
|
45
|
+
readonly severity: 'info' | 'warning' | 'degraded';
|
|
46
|
+
/**
|
|
47
|
+
* Which failure shape the notice describes. The caller's `state()` decides WHETHER it matches; this
|
|
48
|
+
* field is carried so the caller's own wiring (and the runtime-deps helper) can map one spec onto
|
|
49
|
+
* several observed states without inventing a second type.
|
|
50
|
+
*/
|
|
51
|
+
readonly when: 'failed' | 'missing' | 'degraded';
|
|
52
|
+
/** The line itself, self-contained (no path into this repository's docs). Returned verbatim. */
|
|
53
|
+
readonly body: string;
|
|
54
|
+
}
|
|
55
|
+
/** The registry {@link createSessionNotices} returns. */
|
|
56
|
+
export interface SessionNoticeRegistry {
|
|
57
|
+
/**
|
|
58
|
+
* Register one notice with the caller's state predicate.
|
|
59
|
+
*
|
|
60
|
+
* `state()` is called on every `consume`, so it must be cheap, total and side-effect free; a throw
|
|
61
|
+
* is a warning and skips that notice for that call (it is NOT latched, so a later consume may still
|
|
62
|
+
* deliver it). Registration is guarded the same way: a malformed spec, or a second registration of
|
|
63
|
+
* the same id, is a warning and is ignored — never a throw, because registration happens on the
|
|
64
|
+
* plugin's mount path.
|
|
65
|
+
*/
|
|
66
|
+
register(spec: SessionNoticeSpec, state: () => boolean): void;
|
|
67
|
+
/**
|
|
68
|
+
* The notices that match RIGHT NOW and have not yet been delivered to `sessionId`, merged into one
|
|
69
|
+
* array of bodies: severity first (`degraded` > `warning` > `info`), then registration order.
|
|
70
|
+
*
|
|
71
|
+
* Every returned body is latched for that session id at the moment it is returned, so a notice is
|
|
72
|
+
* emitted at most once per session id even if `state()` flips back to `true` later. `consume` never
|
|
73
|
+
* throws: a throwing `state()` is a warning and that notice is skipped.
|
|
74
|
+
*
|
|
75
|
+
* `ownerOnly: true` is the caller's claim that this call is on behalf of the session that OWNS the
|
|
76
|
+
* plugin's delivery — the family's "the caller owns owner/worker filtering" rule. Only a claimed
|
|
77
|
+
* call is served, so a worker/other session stays quiet by not claiming; the caller that does want a
|
|
78
|
+
* line in every session simply claims from every session (the latch stays per session id either
|
|
79
|
+
* way). A call without the claim returns `[]` and latches nothing.
|
|
80
|
+
*/
|
|
81
|
+
consume(sessionId: string, options?: {
|
|
82
|
+
ownerOnly?: boolean;
|
|
83
|
+
}): string[];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build one session-notice registry.
|
|
87
|
+
*
|
|
88
|
+
* @param logger - optional warning sink. Omitted means diagnostics are dropped (the registry still
|
|
89
|
+
* never throws); a throwing sink is swallowed for the same reason.
|
|
90
|
+
* @returns the registry; every member is total.
|
|
91
|
+
*/
|
|
92
|
+
export declare function createSessionNotices(logger?: {
|
|
93
|
+
warn(message: string): void;
|
|
94
|
+
}): SessionNoticeRegistry;
|
|
95
|
+
//# sourceMappingURL=session_notices.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session_notices.d.ts","sourceRoot":"","sources":["../src/session_notices.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,yFAAyF;IACzF,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,kFAAkF;IAClF,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,UAAU,CAAA;IAClD;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,UAAU,CAAA;IAChD,gGAAgG;IAChG,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED,yDAAyD;AACzD,MAAM,WAAW,qBAAqB;IACpC;;;;;;;;OAQG;IACH,QAAQ,CAAC,IAAI,EAAE,iBAAiB,EAAE,KAAK,EAAE,MAAM,OAAO,GAAG,IAAI,CAAA;IAC7D;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,MAAM,EAAE,CAAA;CACxE;AA6CD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE;IAAE,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,qBAAqB,CA+FpG"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped notices — the generic, in-memory channel through which a plugin tells ONE agent
|
|
3
|
+
* session that one of its optional capabilities is not ready.
|
|
4
|
+
*
|
|
5
|
+
* The defect this exists for is a shape, not a package: a plugin provisions an optional capability
|
|
6
|
+
* (a shared runtime-dependency root, a model cache, an archive) in the BACKGROUND, mounts either way,
|
|
7
|
+
* and then has to say so in the owner's prompt — exactly once, with the caller's own wording, and
|
|
8
|
+
* without ever being able to refuse the mount or to leak a stale line after the capability healed.
|
|
9
|
+
* Every family plugin re-implemented that bookkeeping locally; this is the ONE implementation, so a
|
|
10
|
+
* fix here is a base release and not one rebuild per plugin.
|
|
11
|
+
*
|
|
12
|
+
* The registry deliberately knows nothing else:
|
|
13
|
+
*
|
|
14
|
+
* - **No wording.** {@link SessionNoticeSpec.body} is returned VERBATIM; the registry adds no prefix,
|
|
15
|
+
* no severity label and no punctuation. Base names no package, command or user-facing sentence.
|
|
16
|
+
* - **No state of its own.** `state()` is the caller's predicate and it is evaluated on EVERY
|
|
17
|
+
* {@link SessionNoticeRegistry.consume}: a healed dependency simply stops matching, without the
|
|
18
|
+
* registry having to know what "healed" means.
|
|
19
|
+
* - **No session identity.** The caller supplies the session id and decides which sessions may see a
|
|
20
|
+
* line ({@link SessionNoticeRegistry.consume}'s `ownerOnly`); the registry only remembers, per
|
|
21
|
+
* session id, which notice ids it has already delivered.
|
|
22
|
+
* - **No persistence.** Session ids live in a `Map` for the lifetime of the instance. Nothing here
|
|
23
|
+
* touches the filesystem, a database, `process.env` or any global: a notice is a prompt line, not
|
|
24
|
+
* a record.
|
|
25
|
+
* - **No throwing.** `consume` runs on the prompt path of a live agent; a caller bug or a throwing
|
|
26
|
+
* `state()` must cost one skipped line, never the turn.
|
|
27
|
+
*
|
|
28
|
+
* The registry is a LIBRARY, not a DSH plugin: it registers no hook, mounts nothing and reads no
|
|
29
|
+
* configuration. The caller owns the lifecycle (which hook, which session, which text) and the
|
|
30
|
+
* registry owns only the once-per-session bookkeeping.
|
|
31
|
+
*
|
|
32
|
+
* @module session_notices
|
|
33
|
+
*/
|
|
34
|
+
/** The severity order of {@link SessionNoticeSpec.severity}, loudest first. */
|
|
35
|
+
const SEVERITY_RANK = {
|
|
36
|
+
degraded: 0,
|
|
37
|
+
warning: 1,
|
|
38
|
+
info: 2,
|
|
39
|
+
};
|
|
40
|
+
/** The severities this build understands (a JS caller can hand over anything). */
|
|
41
|
+
const SEVERITIES = ['info', 'warning', 'degraded'];
|
|
42
|
+
/** The conditions this build understands. */
|
|
43
|
+
const CONDITIONS = ['failed', 'missing', 'degraded'];
|
|
44
|
+
/** `<plugin>:<rest>` — a notice id must be namespaced, see {@link SessionNoticeSpec.id}. */
|
|
45
|
+
const NAMESPACED_ID = /^[^\s:]+:\S+$/u;
|
|
46
|
+
/** Why a spec cannot be registered, or `undefined` when it can. Reads every field once. */
|
|
47
|
+
function specProblem(spec, state) {
|
|
48
|
+
if (typeof spec !== 'object' || spec === null)
|
|
49
|
+
return 'spec 必须是对象';
|
|
50
|
+
const candidate = spec;
|
|
51
|
+
if (typeof candidate.id !== 'string' || !NAMESPACED_ID.test(candidate.id)) {
|
|
52
|
+
return `id 必须以 "<plugin>:" 前缀化(收到 ${JSON.stringify(candidate.id)})`;
|
|
53
|
+
}
|
|
54
|
+
if (typeof candidate.severity !== 'string' || !SEVERITIES.includes(candidate.severity)) {
|
|
55
|
+
return `severity 只能是 ${SEVERITIES.join(' / ')}(收到 ${JSON.stringify(candidate.severity)})`;
|
|
56
|
+
}
|
|
57
|
+
if (typeof candidate.when !== 'string' || !CONDITIONS.includes(candidate.when)) {
|
|
58
|
+
return `when 只能是 ${CONDITIONS.join(' / ')}(收到 ${JSON.stringify(candidate.when)})`;
|
|
59
|
+
}
|
|
60
|
+
if (typeof candidate.body !== 'string' || candidate.body.trim() === '')
|
|
61
|
+
return 'body 必须是非空字符串';
|
|
62
|
+
if (typeof state !== 'function')
|
|
63
|
+
return 'state 必须是函数';
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Build one session-notice registry.
|
|
68
|
+
*
|
|
69
|
+
* @param logger - optional warning sink. Omitted means diagnostics are dropped (the registry still
|
|
70
|
+
* never throws); a throwing sink is swallowed for the same reason.
|
|
71
|
+
* @returns the registry; every member is total.
|
|
72
|
+
*/
|
|
73
|
+
export function createSessionNotices(logger) {
|
|
74
|
+
const entries = [];
|
|
75
|
+
/** notice id → the session ids it was already delivered to (the once-per-session latch). */
|
|
76
|
+
const delivered = new Map();
|
|
77
|
+
const warn = (message) => {
|
|
78
|
+
try {
|
|
79
|
+
logger?.warn(message);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// A logging failure must never break the prompt path this registry exists to serve.
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const reasonOf = (error) => {
|
|
86
|
+
try {
|
|
87
|
+
if (error instanceof Error && typeof error.message === 'string' && error.message !== '')
|
|
88
|
+
return error.message;
|
|
89
|
+
return String(error);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return 'unknown';
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const wasDelivered = (id, sessionId) => delivered.get(id)?.has(sessionId) === true;
|
|
96
|
+
const latch = (id, sessionId) => {
|
|
97
|
+
let sessions = delivered.get(id);
|
|
98
|
+
if (sessions === undefined) {
|
|
99
|
+
sessions = new Set();
|
|
100
|
+
delivered.set(id, sessions);
|
|
101
|
+
}
|
|
102
|
+
sessions.add(sessionId);
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
register(spec, state) {
|
|
106
|
+
try {
|
|
107
|
+
const problem = specProblem(spec, state);
|
|
108
|
+
if (problem !== undefined) {
|
|
109
|
+
warn(`session-notices: 忽略一个非法 notice — ${problem}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (entries.some(entry => entry.id === spec.id)) {
|
|
113
|
+
// "An id is emitted at most once" only has one meaning: the first registration owns the id.
|
|
114
|
+
warn(`session-notices: notice id 重复,保留先注册的那个 — ${spec.id}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// Snapshot the delivery fields: the registry's decisions (ordering and the returned body) must
|
|
118
|
+
// not change because the caller later mutated the object it registered. `when` is validated
|
|
119
|
+
// above but belongs to the caller's own mapping — the registry matches on `state()` alone.
|
|
120
|
+
entries.push({
|
|
121
|
+
id: spec.id,
|
|
122
|
+
severity: spec.severity,
|
|
123
|
+
body: spec.body,
|
|
124
|
+
state,
|
|
125
|
+
order: entries.length,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
warn(`session-notices: 注册 notice 失败,已忽略(${reasonOf(error)})`);
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
consume(sessionId, options) {
|
|
133
|
+
try {
|
|
134
|
+
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
135
|
+
warn('session-notices: consume 需要一个非空的 session id');
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
if (options?.ownerOnly !== true)
|
|
139
|
+
return [];
|
|
140
|
+
const matched = [];
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
if (wasDelivered(entry.id, sessionId))
|
|
143
|
+
continue;
|
|
144
|
+
let active;
|
|
145
|
+
try {
|
|
146
|
+
active = entry.state();
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
// Skipped, NOT latched: the predicate failed, which says nothing about the notice itself.
|
|
150
|
+
warn(`session-notices: notice ${entry.id} 的 state() 抛错,本次跳过(${reasonOf(error)})`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (active !== true)
|
|
154
|
+
continue;
|
|
155
|
+
matched.push({ id: entry.id, body: entry.body, rank: SEVERITY_RANK[entry.severity], order: entry.order });
|
|
156
|
+
}
|
|
157
|
+
matched.sort((left, right) => left.rank - right.rank || left.order - right.order);
|
|
158
|
+
const lines = [];
|
|
159
|
+
for (const item of matched) {
|
|
160
|
+
lines.push(item.body);
|
|
161
|
+
latch(item.id, sessionId);
|
|
162
|
+
}
|
|
163
|
+
return lines;
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
warn(`session-notices: consume 失败,本次不返回任何 notice(${reasonOf(error)})`);
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=session_notices.js.map
|