dsh-ptc-plus 0.5.3 → 0.5.6
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.en.md +3 -3
- package/README.md +3 -3
- package/client.cjs +3326 -2881
- package/compiler-core.cjs +127 -127
- package/compiler-core.cjs.map +2 -2
- package/docs/README.md +1 -0
- package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -2
- package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -4
- package/docs/architecture.md +1 -1
- package/docs/capability-planning-components.md +6 -6
- package/docs/capability-projection.md +1 -1
- package/docs/capability-surface.md +133 -0
- package/docs/client-ui.md +17 -15
- package/docs/client-visual-contract.md +7 -7
- package/docs/installation.md +14 -7
- package/docs/panel-concept.md +171 -0
- package/docs/publishing.md +1 -1
- package/docs/release-notes.md +1 -1
- package/docs/releases/v0.5.4.md +79 -0
- package/docs/releases/v0.5.5.md +41 -0
- package/docs/releases/v0.5.6.md +41 -0
- package/docs/runtime-reference.md +14 -0
- package/docs/verification.md +1 -1
- package/index.js +117 -30
- package/internal/binding-adoption.js +103 -11
- package/internal/capability-command-receipt.js +99 -15
- package/internal/capability-environment.js +1108 -348
- package/internal/capability-plan.js +18 -2
- package/internal/capability-policy.js +13 -0
- package/internal/capability-selection.js +330 -0
- package/internal/direct-surface-owner.js +17 -4
- package/internal/edit-transport-owner.js +3 -2
- package/internal/host-tool-sdk.js +17 -29
- package/internal/message-sources.js +21 -0
- package/internal/program-bindings.js +13 -4
- package/internal/runtime-bridge-owner.js +9 -9
- package/internal/runtime-config.js +10 -0
- package/internal/session-log-view.js +47 -5
- package/internal/settings-compat.js +6 -0
- package/internal/user-bindings-owner.js +20 -3
- package/package.json +45 -31
- package/src/client-authoring-view.js +7 -8
- package/src/client-capabilities-view.js +871 -948
- package/src/client-capability-sort.js +22 -9
- package/src/client-copy.js +116 -294
- package/src/client-settings-view.js +90 -33
- package/src/client-styles.js +67 -19
- package/src/client.js +46 -21
- package/internal/capability-delivery-proof.js +0 -70
package/index.js
CHANGED
|
@@ -123,6 +123,47 @@ function runCodeDefinitionFor(tools, scope) {
|
|
|
123
123
|
return hostTransportDefinition(tools, scope, RUN_CODE)
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* One catalogue row in the shape `/capabilities list` publishes.
|
|
128
|
+
*
|
|
129
|
+
* The command states the panel's two columns, so an entry carries the same
|
|
130
|
+
* statement the panel's column carries: exactly the dimensions that row decides,
|
|
131
|
+
* each naming the answer its own source gave. A dimension this session recorded
|
|
132
|
+
* no choice for stays stated as `inherit`. The saved column states the words the
|
|
133
|
+
* configuration wrote for that object: its own entry, or `automatic` when it wrote
|
|
134
|
+
* none — a category, server or bottom layer that would answer at runtime is
|
|
135
|
+
* behaviour, so it reaches no cell (capability-surface §8.1c). A server row states
|
|
136
|
+
* `automatic` unless its own `mcpServers.<server>` rule writes availability. Compressing a
|
|
137
|
+
* row into one word would
|
|
138
|
+
* answer for the dimension it dropped — a record that decided disclosure but not
|
|
139
|
+
* availability would read as if the session had decided nothing. No column
|
|
140
|
+
* reports the composition: the panel states supply, record and the receipt, and
|
|
141
|
+
* this listing is the same read rather than a second one that could answer for
|
|
142
|
+
* what a runtime built.
|
|
143
|
+
*/
|
|
144
|
+
function capabilityListingEntry(row) {
|
|
145
|
+
// A server decides availability only: its members carry disclosure, so the panel
|
|
146
|
+
// states one value for such a row and this listing states the same one.
|
|
147
|
+
const statement = column => row.kind === 'server' ? { availability: column.availability }
|
|
148
|
+
: { availability: column.availability, disclosure: column.disclosure }
|
|
149
|
+
return { id: row.id, name: row.name, category: row.category, source: row.source,
|
|
150
|
+
...(typeof row.description === 'string' ? { description: row.description } : {}),
|
|
151
|
+
saved: statement(row.saved), temporary: statement(row.session) }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The composition a pending capability command made, in the shape a receipt carries.
|
|
156
|
+
*
|
|
157
|
+
* A command that composed a plan before the Host accepted a step has to carry it in its
|
|
158
|
+
* receipt: the plan is not in the log yet, so a restart would otherwise have no way to know
|
|
159
|
+
* which composition the command made. `recordedPlan` reads it back by matching the command's
|
|
160
|
+
* own id and raw input, which is why the proposal states both.
|
|
161
|
+
*/
|
|
162
|
+
function pendingProposal(capabilityEnvironment, commandId, rawInput, agent) {
|
|
163
|
+
const message = capabilityEnvironment.pendingChoiceMessage(agent)
|
|
164
|
+
return message === undefined ? undefined : { commandId, rawInput, message }
|
|
165
|
+
}
|
|
166
|
+
|
|
126
167
|
/** Register the session-bound REPL runtime. */
|
|
127
168
|
function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolSdkSchemasForAgent, sessionId, sessionLog, settingsForCommand, readPromptText) {
|
|
128
169
|
const presentationGeneration = randomUUID()
|
|
@@ -375,7 +416,10 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
|
|
|
375
416
|
valueLimits: valueLimitsFromConfig(activeConfig),
|
|
376
417
|
})
|
|
377
418
|
capabilityEnvironment = createCapabilityEnvironment({ ctx, config: activeConfig,
|
|
378
|
-
toolSchemasForAgent: toolSdkSchemasForAgent,
|
|
419
|
+
toolSchemasForAgent: toolSdkSchemasForAgent,
|
|
420
|
+
// The panel's binding rows report the same catalog `/binding` acts on, so the
|
|
421
|
+
// read borrows the bindings owner's `list()` instead of opening a second store.
|
|
422
|
+
readUserBindings: () => userBindings.list(),
|
|
379
423
|
store: new CapabilityStatisticsStore({ ctx }) })
|
|
380
424
|
runtimeBridge = createRuntimeBridgeOwner({
|
|
381
425
|
ctx,
|
|
@@ -430,6 +474,10 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
|
|
|
430
474
|
let unreadableTransportReported = false
|
|
431
475
|
disposers.push(ctx.on('system-prompt/assemble', async (assembly, context, next) => {
|
|
432
476
|
const projected = await directSurface.assemble(assembly, context, next)
|
|
477
|
+
// A capability observation states what the Host declares. It prepares no session,
|
|
478
|
+
// so it neither re-applies the model-facing slots nor reports a transport the
|
|
479
|
+
// operator can do nothing about from a read.
|
|
480
|
+
if (context?.ptcPlusCapabilityObservation === true) return projected
|
|
433
481
|
if (runCodeDefinitionFor(ctx.tools, context?.agent) !== undefined) runtimeBridge.prepareAgent(context?.agent)
|
|
434
482
|
else if (!unreadableTransportReported && hostTransportUnreadable(ctx.tools, context?.agent)) {
|
|
435
483
|
// The host declares the reserved transport and this plugin cannot read it.
|
|
@@ -462,7 +510,7 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
|
|
|
462
510
|
const capabilitiesRpc = ctx.inject(['ptcPlusRpc'], scope => {
|
|
463
511
|
scope.effect(() => scope.ptcPlusRpc.register(RPC_CONTRACTS.capabilities, async (operation, payload, signal) => {
|
|
464
512
|
signal.throwIfAborted()
|
|
465
|
-
if (operation === 'catalog') return { ok: true, value: await capabilityEnvironment.describeCatalog(signal) }
|
|
513
|
+
if (operation === 'catalog') return { ok: true, value: await capabilityEnvironment.describeCatalog(payload, signal) }
|
|
466
514
|
if (operation === 'targets') return { ok: true, value: capabilityEnvironment.describeTargets() }
|
|
467
515
|
if (operation === 'session-catalog') return { ok: true, value: await capabilityEnvironment.describeSessionCatalog(payload, signal) }
|
|
468
516
|
if (operation === 'allocation') return { ok: true, value: await capabilityEnvironment.observeAllocation(payload, signal) }
|
|
@@ -478,41 +526,58 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
|
|
|
478
526
|
input: { hint: 'refresh | list [all|skills|tools|mcp|mcp_tools] [query] | set session|global <id> [availability|disclosure] <choice>' },
|
|
479
527
|
handler: async ({ commandId, agent, rawInput, signal }) => {
|
|
480
528
|
const input = rawInput.trim()
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
529
|
+
// A composition the Host has not committed yet is not in the log, so a pending
|
|
530
|
+
// receipt carries it as the proposal it is: a restart reads that proposal back as
|
|
531
|
+
// the plan the command made. A record needs no proposal — it is in the log already.
|
|
532
|
+
const receipt = (state, error, record) => capabilityCommandReceipt(state, String(agent.session.id), error,
|
|
533
|
+
state === 'pending' ? pendingProposal(capabilityEnvironment, commandId, rawInput, agent) : undefined, record)
|
|
485
534
|
const selection = /^set (session|global) (\S+) (?:(availability|disclosure) )?(full|on-demand|closed|automatic|inherit|enabled|host|adaptive)$/.exec(input)
|
|
486
535
|
if (selection) {
|
|
487
|
-
|
|
536
|
+
// The write is stated before it happens: the receipt names the record
|
|
537
|
+
// this command asked for, and whether that record exists is the answer
|
|
538
|
+
// the command itself returns.
|
|
539
|
+
const record = { scope: selection[1], id: decodeURIComponent(selection[2]),
|
|
540
|
+
...(selection[3] === undefined ? {} : { dimension: selection[3] }), selection: selection[4] }
|
|
488
541
|
const changed = await capabilityEnvironment.change(agent, {
|
|
489
|
-
scope:
|
|
542
|
+
scope: record.scope, id: record.id, dimension: selection[3], selection: record.selection,
|
|
490
543
|
}, signal, settingsForCommand?.())
|
|
491
|
-
// A
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
|
|
544
|
+
// A receipt states what this command wrote — which entry, which choice
|
|
545
|
+
// and which session or global configuration now records it. What a cell
|
|
546
|
+
// later obtained is that row's own `evidence` field, not a sentence here.
|
|
547
|
+
// A session choice is a record of this session's log, so it is durable the
|
|
548
|
+
// moment the command returns and needs no waiting state.
|
|
549
|
+
const state = changed.sessionApplied ? 'recorded' : 'saved-only'
|
|
495
550
|
return { kind: changed.sessionApplied ? 'success' : 'error',
|
|
496
|
-
text: receipt(changed.
|
|
551
|
+
text: receipt(state, changed.error, state === 'recorded' ? record : undefined) }
|
|
497
552
|
}
|
|
498
553
|
const listing = /^list(?: (all|skills|tools|mcp|mcp_tools))?(?: (.*))?$/.exec(input)
|
|
499
554
|
if (listing) {
|
|
500
|
-
|
|
555
|
+
// The listing is the panel's own read: one session catalogue, projected
|
|
556
|
+
// into the columns this command states. The session's scope supplies the
|
|
557
|
+
// declared face while the Host holds its agent, and the plan its log froze
|
|
558
|
+
// supplies it afterwards; the saved configuration states the saved column.
|
|
559
|
+
// Nothing about the PTC runtime enters it, so the two surfaces cannot
|
|
560
|
+
// disagree about which ids exist.
|
|
561
|
+
const catalog = await capabilityEnvironment.describeSessionCatalog({
|
|
562
|
+
sessionId: String(agent.session.id ?? agent.id),
|
|
563
|
+
}, signal)
|
|
501
564
|
const query = (listing[2] ?? '').toLocaleLowerCase()
|
|
502
565
|
const kind = listing[1] ?? 'all'
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
566
|
+
const entries = catalog.rows
|
|
567
|
+
.filter(row => row.kind !== 'binding')
|
|
568
|
+
.filter(row => kind === 'all' || kind === 'skills' && row.category === 'skill'
|
|
569
|
+
|| kind === 'tools' && row.category === 'tool' && row.source?.kind !== 'mcp'
|
|
570
|
+
|| kind === 'mcp' && ['server', 'resource'].includes(row.category)
|
|
571
|
+
|| kind === 'mcp_tools' && row.category === 'tool' && row.source?.kind === 'mcp')
|
|
572
|
+
.filter(row => !query || `${row.id} ${row.name ?? ''} ${row.description ?? ''}`.toLocaleLowerCase().includes(query))
|
|
573
|
+
.map(capabilityListingEntry)
|
|
574
|
+
return { kind: 'success', text: JSON.stringify({ schema: 'ptc-capability-catalog/v1', entries }) }
|
|
509
575
|
}
|
|
510
576
|
if (input !== '' && input !== 'refresh') return { kind: 'error', text: receipt('usage') }
|
|
511
577
|
if (input === 'refresh') {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
await capabilityEnvironment.refresh(agent, undefined, signal, commandId)
|
|
578
|
+
// The command acts on the session it was invoked in; the agent is the
|
|
579
|
+
// argument that states which composition to replace.
|
|
580
|
+
await capabilityEnvironment.refresh(agent, undefined, signal)
|
|
516
581
|
// A refresh always republishes the saved configuration; when that
|
|
517
582
|
// configuration has capability management or context off, report
|
|
518
583
|
// the off state instead of claiming an updated environment.
|
|
@@ -684,11 +749,16 @@ export const apply = (ctx, config = {}) => {
|
|
|
684
749
|
function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatileConfig) {
|
|
685
750
|
let settingsWriter
|
|
686
751
|
const sessionLog = createSessionLogOwner(ctx)
|
|
687
|
-
|
|
688
|
-
|
|
752
|
+
// The second argument is the step's admission. Naming it is how a caller states the set
|
|
753
|
+
// the Host actually delivers for that step; omitting it reads every definition
|
|
754
|
+
// registered at this scope, which is the only view a call outside a model step has.
|
|
755
|
+
const toolSchemasForAgent = (agent, toolSet = undefined) => typeof ctx.tools.schemas === 'function'
|
|
756
|
+
? ctx.tools.schemas(agent, toolSet)
|
|
689
757
|
: []
|
|
690
|
-
// SDK contracts include output schemas; model-facing schemas remain unchanged.
|
|
691
|
-
|
|
758
|
+
// SDK contracts include output schemas; model-facing schemas remain unchanged. The
|
|
759
|
+
// admission is read through the same registry, so a contract describes the definition
|
|
760
|
+
// this step delivers rather than one the registry would resolve.
|
|
761
|
+
const toolSdkSchemasForAgent = (agent, toolSet = undefined) => toolSdkSchemas(ctx.tools, agent, toolSet)
|
|
692
762
|
const sessionId = agent => {
|
|
693
763
|
const id = agent?.session?.id ?? agent?.id
|
|
694
764
|
return id === undefined ? undefined : String(id)
|
|
@@ -985,11 +1055,28 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
|
|
|
985
1055
|
try {
|
|
986
1056
|
if (settingsWriter?.update === undefined) return
|
|
987
1057
|
const previousConfig = controller.committedConfig()
|
|
1058
|
+
// A policy this process synthesized is not a record the settings document holds: write it
|
|
1059
|
+
// back and the next resolution reports it as saved, so the rollback removes the field with
|
|
1060
|
+
// the service's own `unset` op when it offers one. The two switches derived beside it are
|
|
1061
|
+
// restated to the committed values; a service without `unset` keeps the previous, narrower
|
|
1062
|
+
// behaviour and the residual is stated in capability-surface §8.7.
|
|
1063
|
+
const keepDerived = previousConfig?.capabilityPolicySaved === true
|
|
1064
|
+
// Only a committed configuration that reports it saved no policy lets the rollback remove
|
|
1065
|
+
// one: with no committed record this process does not know what the document holds, and
|
|
1066
|
+
// deleting a policy it never wrote would erase rules the user saved.
|
|
1067
|
+
const removable = previousConfig !== undefined && previousConfig.capabilityPolicySaved === false
|
|
1068
|
+
&& typeof settingsWriter.mutate === 'function'
|
|
988
1069
|
const patch = previousConfig === undefined
|
|
989
1070
|
? { enabled: false }
|
|
990
|
-
: Object.fromEntries(CONFIG_FIELDS
|
|
1071
|
+
: Object.fromEntries(CONFIG_FIELDS
|
|
1072
|
+
// Only the policy is synthesized with the two switches; a writer without `mutate` cannot
|
|
1073
|
+
// remove it, so it omits that one field and still restates everything else it derived.
|
|
1074
|
+
.filter(field => keepDerived || field.key !== 'capabilityPolicy')
|
|
1075
|
+
.map(field => [field.key, previousConfig[field.key]]))
|
|
991
1076
|
try {
|
|
992
|
-
await settingsWriter.update(patch)
|
|
1077
|
+
if (removable && Object.keys(patch).length > 0) await settingsWriter.update(patch)
|
|
1078
|
+
if (removable) await settingsWriter.mutate([{ op: 'unset', path: ['capabilityPolicy'] }])
|
|
1079
|
+
else await settingsWriter.update(patch)
|
|
993
1080
|
} catch (rollbackError) {
|
|
994
1081
|
reportActivationFailure(new Error(
|
|
995
1082
|
`ptc-plus: failed to roll back runtime configuration: ${rollbackError.message}`,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
2
|
-
import { isPtcMessageSource, PTC_MESSAGE_SOURCE_KIND } from './message-sources.js'
|
|
2
|
+
import { isPtcMessageSource, PTC_MESSAGE_SOURCE_KIND, RETIRED_SURFACE_RECORD } from './message-sources.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Session binding adoption: which Global User Binding catalog entries this
|
|
@@ -23,13 +23,28 @@ import { isPtcMessageSource, PTC_MESSAGE_SOURCE_KIND } from './message-sources.j
|
|
|
23
23
|
* `automatic`, which means "the host derives this value". A binding has no host
|
|
24
24
|
* derivation to defer to, so borrowing that word would misdescribe both.
|
|
25
25
|
*
|
|
26
|
-
* The decision is recorded as
|
|
26
|
+
* The decision is recorded as a plugin record in the session log, so it is
|
|
27
27
|
* reconstructed by replaying that log after a restart instead of being kept in
|
|
28
|
-
* process memory.
|
|
28
|
+
* process memory. The message form is the historical carrier that 0.5.4 and
|
|
29
|
+
* earlier wrote; it is still read (those sessions keep their decision, and a
|
|
30
|
+
* repaired statement answers through it), and no writer produces it.
|
|
29
31
|
*/
|
|
30
32
|
export const BINDING_ADOPTION_FORM = 'binding-adoption'
|
|
31
33
|
export const BINDING_ADOPTION_SCHEMA = 'ptc-binding-adoption/v1'
|
|
32
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The log record this session's adoption is written as.
|
|
37
|
+
*
|
|
38
|
+
* Adoption is a decision about this session's own environment: the catalog the
|
|
39
|
+
* model reads already reflects it, so the decision needs no message of its own.
|
|
40
|
+
* Writing it as a record makes it durable and immediate — it is taken the moment
|
|
41
|
+
* the control is used, before any step, without entering the model-visible surface
|
|
42
|
+
* (a surface node ahead of the host's protected head would make the log
|
|
43
|
+
* unreadable to every reader). A host that publishes no plugin-record writer
|
|
44
|
+
* refuses the write instead of falling back to that surface node.
|
|
45
|
+
*/
|
|
46
|
+
export const BINDING_ADOPTION_RECORD = 'plugin:ptc-plus/binding-adoption'
|
|
47
|
+
|
|
33
48
|
const MAX_DECLINED = 64
|
|
34
49
|
const ENTRY_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
35
50
|
const PREFIX = 'PTC Plus session binding adoption. This session uses every entry the global catalog supplies except the declined ones. Adoption can only withdraw an entry from this session: it cannot supply, enable or revive one, and the global configuration decides which entries exist at all. Declined entry IDs:'
|
|
@@ -65,6 +80,7 @@ export function bindingAdoptionText(adoption) {
|
|
|
65
80
|
return `${PREFIX}\n${JSON.stringify(normalizeBindingAdoption(adoption).declined)}`
|
|
66
81
|
}
|
|
67
82
|
|
|
83
|
+
/** The historical message this adoption was written as before the record carrier. */
|
|
68
84
|
export function bindingAdoptionMessage(adoption) {
|
|
69
85
|
const text = bindingAdoptionText(adoption)
|
|
70
86
|
const message = createUserMessage({
|
|
@@ -92,24 +108,100 @@ export function readBindingAdoption(value) {
|
|
|
92
108
|
}
|
|
93
109
|
|
|
94
110
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
111
|
+
* How this session's newest adoption statement reads.
|
|
112
|
+
*
|
|
113
|
+
* `none` is the absence of a decision, `decided` is a statement this build can
|
|
114
|
+
* read, and `unreadable` is a statement it cannot. The third state is not the
|
|
115
|
+
* first: an unreadable newest statement still supersedes every earlier record,
|
|
116
|
+
* so no consumer may answer from the older one in its place. `latestBindingAdoption`
|
|
117
|
+
* collapses the last two into `undefined`, which is why the panel asks this
|
|
118
|
+
* function instead: `undefined` there means "no decision to apply", not "the
|
|
119
|
+
* session declined nothing".
|
|
98
120
|
*/
|
|
99
|
-
export function
|
|
100
|
-
if (!Array.isArray(records)) return
|
|
121
|
+
export function classifyBindingAdoption(records) {
|
|
122
|
+
if (!Array.isArray(records)) return Object.freeze({ state: 'none' })
|
|
101
123
|
for (let index = records.length - 1; index >= 0; index -= 1) {
|
|
102
124
|
const record = records[index]
|
|
103
125
|
if (record?.form !== BINDING_ADOPTION_FORM) continue
|
|
104
126
|
try {
|
|
105
|
-
return
|
|
127
|
+
return Object.freeze({ state: 'decided',
|
|
128
|
+
adoption: normalizeBindingAdoption({ schema: BINDING_ADOPTION_SCHEMA, declined: record.declined }) })
|
|
106
129
|
} catch {
|
|
107
130
|
// A record that cannot be read is not evidence for an earlier record
|
|
108
131
|
// either: the newest statement is the current one, and it is unreadable.
|
|
109
|
-
return
|
|
132
|
+
return Object.freeze({ state: 'unreadable' })
|
|
110
133
|
}
|
|
111
134
|
}
|
|
112
|
-
return
|
|
135
|
+
return Object.freeze({ state: 'none' })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Read one committed plugin record back as this session's adoption, or nothing. */
|
|
139
|
+
export function readBindingAdoptionRecord(event) {
|
|
140
|
+
if (event?.type !== BINDING_ADOPTION_RECORD || event.ignorable !== true) return undefined
|
|
141
|
+
try {
|
|
142
|
+
return normalizeBindingAdoption(event.data)
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Whether one event states this session's adoption at all.
|
|
150
|
+
*
|
|
151
|
+
* The record type states it, and so does the historical message form. A repaired statement
|
|
152
|
+
* (`RETIRED_SURFACE_RECORD`) states it only when the payload it kept is this owner's own form:
|
|
153
|
+
* the repair retires every surface node ahead of the protected head, whichever owner wrote it,
|
|
154
|
+
* so the carrier alone says nothing about who the statement belongs to.
|
|
155
|
+
*/
|
|
156
|
+
function statesBindingAdoption(event) {
|
|
157
|
+
if (event?.type === BINDING_ADOPTION_RECORD) return true
|
|
158
|
+
const source = event?.data?.source
|
|
159
|
+
return isPtcMessageSource(source) && source.form === BINDING_ADOPTION_FORM
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Read one adoption out of either carrier, so a reader never has to know which release wrote it —
|
|
164
|
+
* and a repaired statement answers for the owner whose form it carries.
|
|
165
|
+
*/
|
|
166
|
+
export function readBindingAdoptionStatement(event) {
|
|
167
|
+
if (event?.type === BINDING_ADOPTION_RECORD) return readBindingAdoptionRecord(event)
|
|
168
|
+
if (event?.type === RETIRED_SURFACE_RECORD) {
|
|
169
|
+
return event.ignorable === true && statesBindingAdoption(event) ? readBindingAdoption(event.data) : undefined
|
|
170
|
+
}
|
|
171
|
+
return event?.type === 'user/message' ? readBindingAdoption(event.data) : undefined
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* This session's newest adoption statement, read from its own log events.
|
|
176
|
+
*
|
|
177
|
+
* The log is the owner of the decision, so every adoption statement it carries — a record, the
|
|
178
|
+
* message form an older release wrote, or a repaired statement of the same form — is classified,
|
|
179
|
+
* including one this build cannot read. Extracting the statements and classifying them here keeps
|
|
180
|
+
* every reader on the same cut: a reader that filtered to the readable statements first would hand
|
|
181
|
+
* a superseded record the answer, and the panel and the `/binding` command would then disagree
|
|
182
|
+
* about which record governs this session.
|
|
183
|
+
*/
|
|
184
|
+
export function bindingAdoptionOf(events) {
|
|
185
|
+
const records = []
|
|
186
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
187
|
+
if (event?.type !== 'user/message' && event?.type !== BINDING_ADOPTION_RECORD
|
|
188
|
+
&& event?.type !== RETIRED_SURFACE_RECORD) continue
|
|
189
|
+
// A repair retires every owner's surface node, so only a statement that kept this owner's
|
|
190
|
+
// form joins the ordering; another owner's retired node is not an adoption at all.
|
|
191
|
+
if (!statesBindingAdoption(event)) continue
|
|
192
|
+
records.push({ form: BINDING_ADOPTION_FORM, declined: readBindingAdoptionStatement(event)?.declined })
|
|
193
|
+
}
|
|
194
|
+
return classifyBindingAdoption(records)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The newest adoption this session recorded, or `undefined` while it recorded
|
|
199
|
+
* none this build can read. The newest record replaces earlier ones, because
|
|
200
|
+
* stating a decision again is how a session changes its mind.
|
|
201
|
+
*/
|
|
202
|
+
export function latestBindingAdoption(records) {
|
|
203
|
+
const statement = classifyBindingAdoption(records)
|
|
204
|
+
return statement.state === 'decided' ? statement.adoption : undefined
|
|
113
205
|
}
|
|
114
206
|
|
|
115
207
|
/**
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Structured command outcome for locale-owned Client receipts; text remains a CLI fallback.
|
|
3
|
+
*
|
|
4
|
+
* A receipt states the one write a command performed: which entry, which choice,
|
|
5
|
+
* and which owner now records it. Whether a cell later reached that entry is a
|
|
6
|
+
* different question with a different owner — a catalogue row's `evidence` field,
|
|
7
|
+
* where a marker means an accepted execution obtained it, and which the page does
|
|
8
|
+
* not state — so a receipt that restated
|
|
9
|
+
* a degree of proof would answer a question this command never asked and could
|
|
10
|
+
* not answer.
|
|
11
|
+
*/
|
|
4
12
|
const PREFIX = '\n\nPTC Plus capability refresh receipt:\n'
|
|
5
13
|
const FALLBACKS = Object.freeze({
|
|
6
14
|
'saved-only': 'Global choice saved, but the session update failed; inspect and retry session',
|
|
@@ -12,34 +20,110 @@ const FALLBACKS = Object.freeze({
|
|
|
12
20
|
off: 'Capability management is off; the session stays with the Host capabilities for session',
|
|
13
21
|
})
|
|
14
22
|
const STATES = new Set(['recorded', 'pending', 'unavailable', 'usage', 'saved-only', 'paused', 'off'])
|
|
23
|
+
const SCOPES = new Set(['session', 'global'])
|
|
24
|
+
const DIMENSIONS = new Set(['availability', 'disclosure'])
|
|
25
|
+
// Every choice `/capabilities set` accepts, so a receipt cannot name a write the
|
|
26
|
+
// command would have refused.
|
|
27
|
+
const SELECTIONS = new Set(['full', 'on-demand', 'closed', 'automatic', 'inherit', 'enabled', 'host', 'adaptive'])
|
|
28
|
+
/**
|
|
29
|
+
* What the receipt's one-line sentence needs from an id: no whitespace, bounded length.
|
|
30
|
+
*
|
|
31
|
+
* Which ids a choice may name is the record vocabulary's question
|
|
32
|
+
* (`capability-selection.js`), answered once where the record is written. Asking it
|
|
33
|
+
* again here would be a second owner of the same vocabulary, and a stricter second
|
|
34
|
+
* owner refuses a record the session already holds: the write lands, the receipt cannot
|
|
35
|
+
* be composed, and the command reports a failure it did not have. This predicate is a
|
|
36
|
+
* strict superset of that vocabulary, so every record a writer accepts is reportable.
|
|
37
|
+
*/
|
|
38
|
+
const ENTRY_ID = /^\S{1,256}$/
|
|
39
|
+
/**
|
|
40
|
+
* Releases up to 0.5.4 appended a saved/delivered/usable report to the same
|
|
41
|
+
* receipt. Such a receipt stays readable — the Client renders the outcome it
|
|
42
|
+
* recorded instead of falling back to its raw text — and the report is dropped
|
|
43
|
+
* before any consumer sees it. Its wording is frozen history this build does not
|
|
44
|
+
* reproduce, so its opening segment plus the stored facts are the whole check.
|
|
45
|
+
*/
|
|
46
|
+
const LEGACY_REPORT = ' Saved: '
|
|
47
|
+
|
|
48
|
+
function isRecord(value) {
|
|
49
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One written choice, closed over the vocabulary the writer accepts. */
|
|
53
|
+
function commandRecord(value) {
|
|
54
|
+
if (!isRecord(value) || !SCOPES.has(value.scope) || typeof value.id !== 'string' || !ENTRY_ID.test(value.id)
|
|
55
|
+
|| !SELECTIONS.has(value.selection)
|
|
56
|
+
|| value.dimension !== undefined && !DIMENSIONS.has(value.dimension)
|
|
57
|
+
|| Reflect.ownKeys(value).length !== (value.dimension === undefined ? 3 : 4)) {
|
|
58
|
+
throw new TypeError('invalid capability command record')
|
|
59
|
+
}
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
scope: value.scope,
|
|
62
|
+
id: value.id,
|
|
63
|
+
...(value.dimension === undefined ? {} : { dimension: value.dimension }),
|
|
64
|
+
selection: value.selection,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The sentence a non-Client reader gets; the Client states the same facts in its own words. */
|
|
69
|
+
function recordFallback(record) {
|
|
70
|
+
const dimension = record.dimension === undefined ? '' : ` ${record.dimension}`
|
|
71
|
+
return `Recorded ${record.id}${dimension} = ${record.selection}`
|
|
72
|
+
+ (record.scope === 'global' ? ' in the global configuration.' : ' for this session.')
|
|
73
|
+
}
|
|
15
74
|
|
|
16
|
-
export function capabilityCommandReceipt(state, sessionId, error, proposal,
|
|
75
|
+
export function capabilityCommandReceipt(state, sessionId, error, proposal, record) {
|
|
17
76
|
if (!STATES.has(state) || typeof sessionId !== 'string' || !sessionId) throw new TypeError('invalid capability command receipt')
|
|
18
77
|
if (error !== undefined && (state !== 'saved-only' || typeof error !== 'string' || !error)) throw new TypeError('invalid capability command error')
|
|
19
78
|
if (proposal !== undefined && (state !== 'pending' || !proposal || typeof proposal.commandId !== 'string'
|
|
20
79
|
|| typeof proposal.rawInput !== 'string' || !proposal.message)) throw new TypeError('invalid capability proposal receipt')
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const layers = proof === undefined ? undefined : normalizeCapabilityProof(proof)
|
|
80
|
+
// Only a receipt that says the write happened names one: a command whose session
|
|
81
|
+
// write failed reports that failure instead of naming a record it never appended.
|
|
82
|
+
if (record !== undefined && state !== 'recorded') throw new TypeError('invalid capability command record state')
|
|
83
|
+
const written = record === undefined ? undefined : commandRecord(record)
|
|
26
84
|
return `${FALLBACKS[state]} ${sessionId}.${error ? ` ${error}` : ''}`
|
|
27
|
-
+ (
|
|
85
|
+
+ (written === undefined ? '' : ` ${recordFallback(written)}`)
|
|
28
86
|
+ PREFIX
|
|
29
87
|
+ JSON.stringify({
|
|
30
88
|
schema: 'ptc-capability-command/v1', state, sessionId,
|
|
31
89
|
...(error === undefined ? {} : { error }),
|
|
32
90
|
...(proposal === undefined ? {} : { proposal }),
|
|
33
|
-
...(
|
|
91
|
+
...(written === undefined ? {} : { record: written }),
|
|
34
92
|
})
|
|
35
93
|
}
|
|
36
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The receipt a command text carries, or `undefined` for any other text.
|
|
97
|
+
*
|
|
98
|
+
* A receipt this build writes is read only when the whole text is exactly what
|
|
99
|
+
* the writer produces from the facts it stores. A 0.5.4 receipt carries the
|
|
100
|
+
* report described above instead, and its visible sentence cannot be reproduced
|
|
101
|
+
* without keeping that wording, so its stored facts decide: the outcome sentence,
|
|
102
|
+
* the field order and the record of what was written must all be the writer's.
|
|
103
|
+
*/
|
|
37
104
|
export function readCapabilityCommandReceipt(text) {
|
|
38
105
|
if (typeof text !== 'string' || !text.includes(PREFIX)) return undefined
|
|
106
|
+
const cut = text.indexOf(PREFIX)
|
|
107
|
+
let stored
|
|
108
|
+
try { stored = JSON.parse(text.slice(cut + PREFIX.length)) } catch { return undefined }
|
|
109
|
+
if (stored?.schema !== 'ptc-capability-command/v1' || !STATES.has(stored.state)) return undefined
|
|
110
|
+
let current
|
|
39
111
|
try {
|
|
40
|
-
|
|
41
|
-
return receipt?.schema === 'ptc-capability-command/v1' && STATES.has(receipt.state)
|
|
42
|
-
&& typeof receipt.sessionId === 'string' && receipt.sessionId
|
|
43
|
-
&& capabilityCommandReceipt(receipt.state, receipt.sessionId, receipt.error, receipt.proposal, receipt.proof) === text ? receipt : undefined
|
|
112
|
+
current = capabilityCommandReceipt(stored.state, stored.sessionId, stored.error, stored.proposal, stored.record)
|
|
44
113
|
} catch { return undefined }
|
|
114
|
+
if (stored.proof === undefined) return current === text ? stored : undefined
|
|
115
|
+
const head = current.slice(0, current.indexOf(PREFIX))
|
|
116
|
+
if (!text.startsWith(head)) return undefined
|
|
117
|
+
const sentence = text.slice(head.length, cut)
|
|
118
|
+
// One report segment, one line, directly after the outcome sentence: the shape
|
|
119
|
+
// the 0.5.4 writer produced from these stored facts.
|
|
120
|
+
if (stored.record !== undefined || !sentence.startsWith(LEGACY_REPORT)
|
|
121
|
+
|| sentence.includes('\n') || sentence.split(LEGACY_REPORT).length !== 2) return undefined
|
|
122
|
+
const fields = Object.keys(JSON.parse(current.slice(current.indexOf(PREFIX) + PREFIX.length)))
|
|
123
|
+
if (Object.keys(stored).join() !== [...fields, 'proof'].join()) return undefined
|
|
124
|
+
return {
|
|
125
|
+
schema: stored.schema, state: stored.state, sessionId: stored.sessionId,
|
|
126
|
+
...(stored.error === undefined ? {} : { error: stored.error }),
|
|
127
|
+
...(stored.proposal === undefined ? {} : { proposal: stored.proposal }),
|
|
128
|
+
}
|
|
45
129
|
}
|