dsh-ptc-plus 0.4.8 → 0.4.10

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.
Files changed (62) hide show
  1. package/README.en.md +11 -3
  2. package/README.md +11 -3
  3. package/client.js +1151 -529
  4. package/cordis.patch.yml +12 -2
  5. package/docs/README.md +1 -0
  6. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +19 -0
  7. package/docs/adr/0019-plugin-settings-and-kill-switch.md +13 -2
  8. package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +1 -1
  9. package/docs/adr/0023-global-user-bindings.md +1 -1
  10. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +82 -18
  11. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +3 -3
  12. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +27 -0
  13. package/docs/adr/0033-gate-client-visual-usability-in-a-browser.md +23 -0
  14. package/docs/architecture.md +3 -3
  15. package/docs/binding-review-placement-design.md +29 -21
  16. package/docs/client-ui.md +20 -8
  17. package/docs/client-visual-contract.md +48 -0
  18. package/docs/durability-design.md +12 -0
  19. package/docs/installation.md +27 -6
  20. package/docs/runtime-reference.md +1 -1
  21. package/docs/user-bindings.en.md +2 -2
  22. package/docs/user-bindings.md +3 -3
  23. package/docs/verification-optimization-work.md +26 -0
  24. package/docs/verification.md +2 -0
  25. package/index.js +28 -7
  26. package/internal/compiler-platform.js +5 -3
  27. package/internal/compiler-service.js +2 -9
  28. package/internal/cordis-tools-owner.js +22 -19
  29. package/internal/direct-surface-owner.js +2 -0
  30. package/internal/edit-transport-owner.js +8 -5
  31. package/internal/execution-provider-owner.js +157 -0
  32. package/internal/execution-seam-compat.js +69 -113
  33. package/internal/isolated-worker.js +12 -8
  34. package/internal/recovery-tips.js +50 -21
  35. package/internal/runtime-bridge-owner.js +88 -57
  36. package/internal/runtime-contexts.js +13 -13
  37. package/internal/session-events.js +40 -4
  38. package/internal/session-journal-recovery.js +678 -181
  39. package/internal/session-log-view.js +189 -43
  40. package/internal/session-runtime.js +58 -10
  41. package/internal/settings-compat.js +12 -2
  42. package/internal/user-bindings-owner.js +6 -5
  43. package/locale/en.json +6 -0
  44. package/locale/zh.json +6 -0
  45. package/package.json +13 -14
  46. package/src/client-authoring-view.js +222 -141
  47. package/src/client-binding-review.js +2 -0
  48. package/src/client-code-editor.js +6 -0
  49. package/src/client-copy.js +10 -0
  50. package/src/client-feature-gates.js +12 -5
  51. package/src/client-host-compat.js +23 -2
  52. package/src/client-primitives.js +249 -0
  53. package/src/client-repl-view.js +3 -3
  54. package/src/client-settings-view.js +2 -29
  55. package/src/client-styles.js +92 -33
  56. package/src/client-tool-view.js +8 -3
  57. package/src/client-workbench.js +3 -3
  58. package/src/client.js +72 -46
  59. package/assets/ptc-plus-bindings-en.png +0 -0
  60. package/assets/ptc-plus-bindings-zh.png +0 -0
  61. package/assets/ptc-plus-repair-en.png +0 -0
  62. package/assets/ptc-plus-repair-zh.png +0 -0
@@ -149,8 +149,9 @@ function containsCompanionSkill(directory, fileExists) {
149
149
 
150
150
  /** Resolve the official Cordis companion Skill across DSH preset generations. */
151
151
  export async function resolveCompanionSkillDirectory(agentPresets, options = {}) {
152
- const fileExists = options.fileExists ?? existsSync
153
152
  const preset = await agentPresets.resolve(CORDIS_PRESET_ID)
153
+ const resolution = typeof options === 'function' ? options() : options
154
+ const fileExists = resolution.fileExists ?? existsSync
154
155
  if (typeof preset?.broken === 'string' && preset.broken.length > 0) {
155
156
  throw new Error(`ptc-plus: DSH cordis preset is unavailable: ${preset.broken}`)
156
157
  }
@@ -166,7 +167,7 @@ export async function resolveCompanionSkillDirectory(agentPresets, options = {})
166
167
  }
167
168
 
168
169
  for (const layout of COMPANION_PACKAGE_LAYOUTS) {
169
- const pkg = options.packageOf?.(layout.name, options.packageBaseUrl)
170
+ const pkg = resolution.packageOf?.(layout.name, resolution.packageBaseUrl)
170
171
  if (pkg === undefined) continue
171
172
  if (pkg?.name !== layout.name || typeof pkg.dir !== 'string' || !isAbsolute(pkg.dir)) {
172
173
  throw new Error(`ptc-plus: DSH pluginPackages returned an invalid ${layout.name} package resource`)
@@ -175,10 +176,10 @@ export async function resolveCompanionSkillDirectory(agentPresets, options = {})
175
176
  if (containsCompanionSkill(directory, fileExists)) return directory
176
177
  }
177
178
 
178
- if (options.packageOf === undefined && options.resolveLegacyPackageManifest !== undefined) {
179
+ if (resolution.packageOf === undefined && resolution.resolveLegacyPackageManifest !== undefined) {
179
180
  let manifest
180
181
  try {
181
- manifest = options.resolveLegacyPackageManifest('@deepseek-ai/dsh-agent-presets/package.json')
182
+ manifest = resolution.resolveLegacyPackageManifest('@deepseek-ai/dsh-agent-presets/package.json')
182
183
  } catch (error) {
183
184
  if (error?.code !== 'MODULE_NOT_FOUND' && error?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw error
184
185
  }
@@ -320,22 +321,24 @@ export function createCordisToolsOwner(
320
321
  const cordisInspectLeases = createCordisInspectLeases(ctx)
321
322
  const scopedCordisPlugin = cordisInspectLeases.plugin(cordisPlugin)
322
323
  const scopedSkillPlugin = exactCompanionSkillPlugin(skillFilesystemPlugin)
323
- const pluginPackages = ctx.get?.('pluginPackages')
324
324
  const packageBaseUrl = ctx.baseUrl
325
- const skillResolution = companionResolution ?? {
326
- packageOf: pluginPackages === undefined
327
- ? undefined
328
- : (name, parentURL) => {
329
- if (typeof pluginPackages?.packageOf !== 'function') {
330
- throw new Error('ptc-plus: cordisToolsEnabled requires the DSH pluginPackages.packageOf API')
331
- }
332
- return pluginPackages.packageOf(name, parentURL)
333
- },
334
- packageBaseUrl,
335
- resolveLegacyPackageManifest: pluginPackages === undefined && typeof packageBaseUrl === 'string'
336
- ? specifier => createRequire(packageBaseUrl).resolve(specifier)
337
- : undefined,
338
- }
325
+ const skillResolution = companionResolution ?? (() => {
326
+ const pluginPackages = ctx.get?.('pluginPackages')
327
+ return {
328
+ packageOf: pluginPackages === undefined
329
+ ? undefined
330
+ : (name, parentURL) => {
331
+ if (typeof pluginPackages?.packageOf !== 'function') {
332
+ throw new Error('ptc-plus: cordisToolsEnabled requires the DSH pluginPackages.packageOf API')
333
+ }
334
+ return pluginPackages.packageOf(name, parentURL)
335
+ },
336
+ packageBaseUrl,
337
+ resolveLegacyPackageManifest: pluginPackages === undefined && typeof packageBaseUrl === 'string'
338
+ ? specifier => createRequire(packageBaseUrl).resolve(specifier)
339
+ : undefined,
340
+ }
341
+ })
339
342
  let disposed = false
340
343
 
341
344
  const leafErrors = error => error instanceof AggregateError
@@ -464,6 +464,7 @@ export function createDirectSurfaceOwner({
464
464
  toolSchemasForAgent,
465
465
  userBindingsForAgent = async () => undefined,
466
466
  setAgentPresentation = async () => {},
467
+ sessionLogView,
467
468
  }) {
468
469
  // Composition is anchored to Agent identity because DSH selects presentation
469
470
  // once per composed agent. Request signals bind the exact assembly to stream
@@ -634,6 +635,7 @@ export function createDirectSurfaceOwner({
634
635
  }
635
636
  const runtimeContexts = sessionPtc
636
637
  ? sessionRuntimeContexts(agent, tipConfig, {
638
+ sessionLogView,
637
639
  cordisRecoveryRequired(view) {
638
640
  return cordisRecovery.required(agent, view)
639
641
  },
@@ -17,7 +17,7 @@ import {
17
17
  editRejectedCell,
18
18
  editRunCodeSchema,
19
19
  } from './rejected-cell-editor.js'
20
- import { editTargetForCall, projectSessionLog } from './session-log-view.js'
20
+ import { projectSessionLog } from './session-log-view.js'
21
21
  import { RUN_CODE } from './runtime-bridge-owner.js'
22
22
  import { isRecord } from './record-utils.js'
23
23
  import {
@@ -67,6 +67,8 @@ export function createEditTransportOwner(ctx, {
67
67
  presentationGeneration,
68
68
  sessionId,
69
69
  toolSchemasForAgent,
70
+ readSessionLog,
71
+ sessionLogView = projectSessionLog,
70
72
  }) {
71
73
  let currentDurableReplay = durableReplay
72
74
  const editExecutionMetadata = new WeakMap()
@@ -143,13 +145,14 @@ export function createEditTransportOwner(ctx, {
143
145
  definition.execute = async (args, exec) => {
144
146
  const agent = exec?.agent
145
147
  const callId = typeof exec?.callId === 'string' ? exec.callId : undefined
146
- const persistedTargetCallSeq = liveToolCallSeq(agent?.session, callId, EDIT_RUN_CODE)
147
- if (persistedTargetCallSeq === undefined && projectSessionLog(agent).editableRun !== undefined) {
148
+ const sessionLog = await readSessionLog(agent?.session, exec.signal)
149
+ const persistedTargetCallSeq = liveToolCallSeq(sessionLog, callId, EDIT_RUN_CODE)
150
+ if (persistedTargetCallSeq === undefined && sessionLogView(agent).editableRun !== undefined) {
148
151
  throw new Error('ptc-plus: edit_run_code requires a unique persisted tool/call event')
149
152
  }
150
153
  const target = persistedTargetCallSeq === undefined
151
154
  ? undefined
152
- : editTargetForCall(agent, callId, persistedTargetCallSeq)
155
+ : sessionLogView(agent, { callId, callSeq: persistedTargetCallSeq }).requestedEditTarget
153
156
  if (target?.source === undefined || target.callSeq === undefined) {
154
157
  return { edited: false, reason: 'no run_code cell is currently eligible for safe editing' }
155
158
  }
@@ -190,7 +193,7 @@ export function createEditTransportOwner(ctx, {
190
193
  agent,
191
194
  signal: exec.signal,
192
195
  })
193
- const tentative = await executeTentative(persistedCallSeq, dispatch)
196
+ const tentative = await executeTentative(persistedCallSeq, dispatch, sessionLog)
194
197
  const inner = tentative.result
195
198
  finalizeTentative = tentative.finalize
196
199
  const journalValue = isRecord(inner?.meta) && Object.hasOwn(inner.meta, JOURNAL_KEY)
@@ -0,0 +1,157 @@
1
+ import { createExecutionSeam, EXECUTION_SEAM_SERVICE } from './execution-seam-compat.js'
2
+
3
+ export const name = 'ptc-plus-execution-provider'
4
+ export const inject = ['loader']
5
+
6
+ export function apply(ctx, config = {}) {
7
+ const ownerFiber = ctx.fiber
8
+ const { originalEntryId = 'ptc-runtime', isolation = 'ptc-plus-original' } = config
9
+ if (typeof originalEntryId !== 'string' || originalEntryId.length === 0
10
+ || typeof isolation !== 'string' || isolation.length === 0) {
11
+ throw new TypeError('ptc-plus: execution provider requires an original entry id and named isolation')
12
+ }
13
+ // Profile rows belong to the Include's tree; the global Loader resolves root IDs.
14
+ const entryTree = ownerFiber.entry?.parent.tree ?? ctx.loader
15
+ const originalEntry = entryTree.resolve(originalEntryId)
16
+ const isolated = () => originalEntry.options.isolate?.[EXECUTION_SEAM_SERVICE] === isolation
17
+ if (!isolated()) throw new Error(`ptc-plus: original entry ${originalEntryId} must isolate ${EXECUTION_SEAM_SERVICE} as ${isolation}`)
18
+ const originalContext = originalEntry.context.extend({ [originalEntry.constructor.key]: undefined })
19
+ let watcher
20
+ let live
21
+ let stopped = false
22
+ let suspended = false
23
+ let generation
24
+ const pending = new Set()
25
+ let resolveAttachment
26
+ let rejectAttachment
27
+ const attachment = new Promise((resolve, reject) => {
28
+ resolveAttachment = resolve
29
+ rejectAttachment = reject
30
+ })
31
+ const readOriginal = () => {
32
+ const original = isolated() ? originalContext.get(EXECUTION_SEAM_SERVICE) : undefined
33
+ if (stopped || original === undefined) {
34
+ throw new Error(`ptc-plus: original ${EXECUTION_SEAM_SERVICE} generation is unavailable`)
35
+ }
36
+ return original
37
+ }
38
+ const release = () => {
39
+ const entry = live
40
+ if (entry === undefined) return
41
+ live = undefined
42
+ entry.seam.retire()
43
+ const unregistration = entry.unregister?.()
44
+ const disposal = Promise.allSettled([unregistration, entry.disposeChild?.()]).then(results => {
45
+ const failures = results.filter(result => result.status === 'rejected').map(result => result.reason)
46
+ if (failures.length > 0) throw new AggregateError(failures, 'ptc-plus: execution provider disposal failed')
47
+ })
48
+ pending.add(disposal)
49
+ disposal.then(() => pending.delete(disposal), error => {
50
+ pending.delete(disposal)
51
+ ctx.logger.warn(error)
52
+ })
53
+ return disposal
54
+ }
55
+ const suspend = () => {
56
+ suspended = true
57
+ const disposal = release()
58
+ const previous = watcher
59
+ watcher = undefined
60
+ return Promise.all([disposal, previous?.dispose()])
61
+ }
62
+ const publish = () => {
63
+ const resolutions = new WeakMap()
64
+ const delegate = {
65
+ get language() { return readOriginal().language },
66
+ get isolation() { return readOriginal().isolation },
67
+ get executionInstructions() { return readOriginal().executionInstructions },
68
+ get sandboxMode() { return readOriginal().sandboxMode },
69
+ get timeout() { return readOriginal().timeout },
70
+ resolve(request) {
71
+ const original = readOriginal()
72
+ const spec = original.resolve(request)
73
+ if (spec !== null && typeof spec === 'object') {
74
+ resolutions.set(spec, { original, generation })
75
+ }
76
+ return spec
77
+ },
78
+ run(spec) {
79
+ const resolved = resolutions.get(spec)
80
+ if (resolved?.generation?.active === false) throw new Error('ptc-plus: resolved spec belongs to an unavailable original generation')
81
+ const original = resolved?.original ?? readOriginal()
82
+ return original.run(spec)
83
+ },
84
+ }
85
+ const seam = createExecutionSeam(delegate, EXECUTION_SEAM_SERVICE)
86
+ const entry = { seam }
87
+ live = entry
88
+ entry.disposeChild = ctx.effect(() => {
89
+ const child = ctx.plugin({
90
+ name: 'ptc-plus-composed-runtime',
91
+ apply(childContext) {
92
+ childContext.effect(() => {
93
+ if (stopped || live !== entry || !isolated()) return () => {}
94
+ entry.unregister = childContext.provide(EXECUTION_SEAM_SERVICE, seam.provider)
95
+ return () => entry.unregister()
96
+ }, 'ptc-plus owned execution registration')
97
+ },
98
+ })
99
+ entry.child = child
100
+ return () => child.dispose()
101
+ }, 'ptc-plus owned execution child')
102
+ return entry.child.await().then(() => {
103
+ if (!stopped && live === entry && entry.unregister !== undefined && entry.child.state === 2) resolveAttachment()
104
+ }, error => {
105
+ rejectAttachment(error)
106
+ throw error
107
+ })
108
+ }
109
+ const watch = () => {
110
+ if (stopped || watcher !== undefined || !isolated()) return
111
+ ctx.effect(() => {
112
+ const observer = originalContext.inject([EXECUTION_SEAM_SERVICE], scope => {
113
+ if (stopped || !isolated()) return
114
+ createExecutionSeam(scope.get(EXECUTION_SEAM_SERVICE), EXECUTION_SEAM_SERVICE)
115
+ const currentGeneration = { active: true }
116
+ generation = currentGeneration
117
+ scope.effect(() => () => {
118
+ currentGeneration.active = false
119
+ if (generation === currentGeneration) generation = undefined
120
+ }, 'ptc-plus original generation scope')
121
+ if (live === undefined) return publish()
122
+ return live.seam.sourceChanged()
123
+ })
124
+ watcher = observer
125
+ return () => observer.dispose()
126
+ }, 'ptc-plus original execution observer')
127
+ }
128
+ ctx.on('loader/partial-dispose', (entry, _legacy, active) => {
129
+ if (entry === originalEntry && (!active || !isolated())) void suspend().catch(error => ctx.logger.warn(error))
130
+ }, { prepend: true, global: true })
131
+ ctx.on('loader/patch-context', (entry, next) => {
132
+ if (entry === originalEntry && !isolated()) void suspend().catch(error => ctx.logger.warn(error))
133
+ next()
134
+ if (entry === originalEntry && isolated() && suspended && !stopped && ownerFiber.state === 2) {
135
+ stopped = true
136
+ resolveAttachment()
137
+ void suspend().catch(error => ctx.logger.warn(error))
138
+ void ownerFiber.restart().catch(error => ctx.logger.warn(error))
139
+ }
140
+ }, { prepend: true, global: true })
141
+ ctx.effect(() => () => {
142
+ stopped = true
143
+ resolveAttachment()
144
+ const suspension = suspend()
145
+ return Promise.all([suspension, ...pending])
146
+ }, 'ptc-plus execution provider composition')
147
+ if (originalEntry.disabled) resolveAttachment()
148
+ const timer = setTimeout(() => rejectAttachment(new Error(
149
+ `ptc-plus: original execution entry ${originalEntryId} did not publish ${EXECUTION_SEAM_SERVICE}`,
150
+ )), 30_000)
151
+ ctx.effect(() => () => clearTimeout(timer), 'ptc-plus original execution provider wait')
152
+ const originalReady = originalEntry.fiber?.await() ?? Promise.resolve()
153
+ return originalReady.then(() => {
154
+ watch()
155
+ return attachment
156
+ }).finally(() => clearTimeout(timer))
157
+ }
@@ -1,39 +1,11 @@
1
- /**
2
- * Own how PTC Plus attaches to the host's program-execution seam.
3
- *
4
- * DSH registers one execution seam per generation. The current release
5
- * registers `ctx.ptcRuntime` (`@deepseek-ai/dsh-ptc-runtime`); the preceding one
6
- * registered `ctx.codeRuntime` (`@deepseek-ai/dsh-code-runtime`). Both run one
7
- * model-written program against host async bindings. They differ in the service
8
- * name, in the call shape (`resolve(request)` then `run(spec)` against a single
9
- * `run(request)`), and in the capability descriptors a provider publishes.
10
- * Which seam is live is decided by the registered service, never by a version
11
- * string, so a deployment on either generation obtains the same behavior.
12
- *
13
- * PTC Plus replaces the execution of session cells with its own persistent
14
- * REPL. The object DSH describes to the model is therefore the plugin's
15
- * execution, not the wrapped provider's, so `takeOver()` answers the
16
- * descriptors the plugin can honor and withholds the ones it cannot. The REPL
17
- * runs every cell in one long-lived worker rather than a fresh process per
18
- * call, applies the session's configured budgets rather than a per-call host
19
- * deadline, and performs no file confinement. DSH gates `timeoutMs` on
20
- * `timeout` and resolves or escalates file policy on `sandboxMode`, so
21
- * withholding those descriptors keeps DSH's authority decisions with DSH
22
- * instead of having this plugin accept an input it would then ignore.
23
- *
24
- * A seam-neutral request carries exactly `program`, `bindings`, and `signal`:
25
- * the fields PTC Plus consumes. A resolved spec's directory, deadline, and
26
- * authority describe the provider whose execution was replaced, and are neither
27
- * forwarded to the session kernel nor re-derived here.
28
- */
29
-
30
1
  /** Cordis service name registered by the current DSH generation. */
31
2
  export const EXECUTION_SEAM_SERVICE = 'ptcRuntime'
32
3
 
33
- /** Cordis service name registered by the preceding DSH generation. */
4
+ /** Historical request-only service name, retained with fixture evidence. */
34
5
  export const LEGACY_EXECUTION_SEAM_SERVICE = 'codeRuntime'
35
6
 
36
7
  const SEAM_SERVICES = Object.freeze([EXECUTION_SEAM_SERVICE, LEGACY_EXECUTION_SEAM_SERVICE])
8
+ const PLUGIN_EXECUTION_SEAM = Symbol.for('dsh-ptc-plus.execution-seam')
37
9
 
38
10
  /**
39
11
  * How long the plugin waits for the host's execution seam before it reports the
@@ -48,34 +20,12 @@ const SEAM_SERVICES = Object.freeze([EXECUTION_SEAM_SERVICE, LEGACY_EXECUTION_SE
48
20
  */
49
21
  const SEAM_ATTACHMENT_TIMEOUT_MS = 30_000
50
22
 
51
- /**
52
- * Descriptors the plugin replaces with the capabilities of its own execution.
53
- * `executionInstructions` describes the wrapped provider's program model, which
54
- * no longer applies; `timeout` and `sandboxMode` gate host inputs the plugin
55
- * cannot honor. Withdrawal is unconditional because the rule is a property of
56
- * the plugin's execution, not of a generation: the preceding generation's
57
- * contract defines none of these members and nothing in its install closure
58
- * reads them, so withdrawing there is inert, while a provider that published
59
- * them stays unable to outlive the takeover.
60
- */
61
- const WITHHELD_DESCRIPTORS = Object.freeze(['executionInstructions', 'sandboxMode', 'timeout'])
62
-
63
23
  /** Read a registered service without declaring an injection requirement. */
64
24
  function readService(ctx, name) {
65
25
  if (typeof ctx?.get === 'function') return ctx.get(name)
66
26
  return ctx?.[name]
67
27
  }
68
28
 
69
- function descriptorsMatch(left, right) {
70
- if (left === undefined || right === undefined) return left === right
71
- return left.value === right.value
72
- && left.get === right.get
73
- && left.set === right.set
74
- && left.writable === right.writable
75
- && left.enumerable === right.enumerable
76
- && left.configurable === right.configurable
77
- }
78
-
79
29
  /**
80
30
  * Build the seam handle for one live execution service.
81
31
  * @param {object} service - the registered `ctx.ptcRuntime` or `ctx.codeRuntime`.
@@ -85,6 +35,7 @@ export function createExecutionSeam(service, serviceName) {
85
35
  if (service === null || typeof service !== 'object') {
86
36
  throw new TypeError(`ptc-plus: ${serviceName} must be an execution service object`)
87
37
  }
38
+ if (typeof service[PLUGIN_EXECUTION_SEAM] === 'function') return service[PLUGIN_EXECUTION_SEAM]()
88
39
  const current = serviceName === EXECUTION_SEAM_SERVICE
89
40
  const upstreamRun = service.run
90
41
  if (typeof upstreamRun !== 'function') {
@@ -95,82 +46,87 @@ export function createExecutionSeam(service, serviceName) {
95
46
  throw new TypeError('ptc-plus: ptcRuntime.resolve must be a function')
96
47
  }
97
48
  let installed
98
- return Object.freeze({
49
+ const sourceListeners = new Set()
50
+ let available = true
51
+ const requireAvailable = () => {
52
+ if (!available) throw new Error(`ptc-plus: original ${serviceName} generation is unavailable`)
53
+ }
54
+ const requireCompatible = () => {
55
+ requireAvailable()
56
+ if (installed !== undefined && service.language !== installed.language) {
57
+ throw new Error(`ptc-plus: current original language ${JSON.stringify(service.language)} is incompatible with the active ${JSON.stringify(installed.language)} runtime`)
58
+ }
59
+ }
60
+ const provider = Object.freeze({
61
+ get language() { requireCompatible(); return service.language },
62
+ get isolation() { requireCompatible(); return installed === undefined ? service.isolation : 'worker-thread' },
63
+ get executionInstructions() { requireCompatible(); return installed === undefined ? service.executionInstructions ?? '' : '' },
64
+ get sandboxMode() { requireCompatible(); return installed === undefined ? service.sandboxMode : undefined },
65
+ get timeout() { requireCompatible(); return installed === undefined ? service.timeout : undefined },
66
+ resolve(request) {
67
+ requireCompatible()
68
+ return current ? upstreamResolve.call(service, request) : request
69
+ },
70
+ run(input) {
71
+ requireCompatible()
72
+ return installed === undefined ? upstreamRun.call(service, input) : installed.execute(input)
73
+ },
74
+ [PLUGIN_EXECUTION_SEAM]() { return seam },
75
+ })
76
+ const seam = Object.freeze({
99
77
  serviceName,
100
- language: service.language,
78
+ get language() { requireAvailable(); return service.language },
79
+ provider,
80
+ get active() { return installed !== undefined && service.language === installed.language },
81
+ onSourceChange(listener) {
82
+ sourceListeners.add(listener)
83
+ return () => sourceListeners.delete(listener)
84
+ },
85
+ sourceChanged() {
86
+ return Promise.all([...sourceListeners].map(listener => listener())).then(() => undefined)
87
+ },
101
88
  /**
102
89
  * Run one program through the provider this plugin wraps. The current
103
90
  * generation resolves the request before running it, exactly as its own
104
91
  * consumer does; the preceding generation accepts the request directly.
105
92
  */
106
93
  invokeUpstream(request) {
94
+ requireCompatible()
107
95
  return current
108
96
  ? upstreamRun.call(service, upstreamResolve.call(service, request))
109
97
  : upstreamRun.call(service, request)
110
98
  },
111
- /**
112
- * Install the plugin's execution entry, and its capability descriptors, on
113
- * the live service. Only one takeover is live at a time.
114
- * @param {(request: object) => Promise<object>} execute - the plugin's entry.
115
- * @returns {() => void} restores every descriptor this call replaced.
116
- */
117
- takeOver(execute) {
118
- if (installed !== undefined) throw new Error('ptc-plus: execution seam already taken over')
119
- const saved = new Map()
120
- const written = new Map()
121
- const requested = new Map()
122
- requested.set('run', {
123
- configurable: true,
124
- writable: true,
125
- value: current
126
- ? spec => execute({
127
- program: spec.program,
128
- bindings: spec.bindings,
129
- signal: spec.signal,
130
- })
131
- : request => execute(request),
132
- })
133
- for (const name of WITHHELD_DESCRIPTORS) {
134
- requested.set(name, { configurable: true, value: name === 'executionInstructions' ? '' : undefined })
135
- }
136
- const extensible = Object.isExtensible(service)
137
- for (const [name, descriptor] of requested) {
138
- const previous = Object.getOwnPropertyDescriptor(service, name)
139
- saved.set(name, previous)
140
- const next = { enumerable: previous?.enumerable ?? false, ...descriptor }
141
- requested.set(name, next)
142
- const probe = {}
143
- if (previous !== undefined) Object.defineProperty(probe, name, previous)
144
- if (!extensible) Object.preventExtensions(probe)
145
- Object.defineProperty(probe, name, next)
146
- }
147
- try {
148
- for (const [name, descriptor] of requested) {
149
- Object.defineProperty(service, name, descriptor)
150
- written.set(name, Object.getOwnPropertyDescriptor(service, name))
151
- }
152
- } catch (error) {
153
- for (const name of [...written.keys()].reverse()) {
154
- if (!descriptorsMatch(Object.getOwnPropertyDescriptor(service, name), written.get(name))) continue
155
- const descriptor = saved.get(name)
156
- if (descriptor === undefined) delete service[name]
157
- else Object.defineProperty(service, name, descriptor)
158
- }
159
- throw error
160
- }
99
+ runUpstream(spec) {
100
+ requireCompatible()
101
+ return upstreamRun.call(service, spec)
102
+ },
103
+ installExecute(execute) {
104
+ requireAvailable()
105
+ if (typeof execute !== 'function') throw new TypeError('ptc-plus: execution entry must be a function')
106
+ if (installed !== undefined) throw new Error('ptc-plus: execution seam already installed')
107
+ const entry = { execute, language: service.language }
108
+ installed = entry
161
109
  const restore = () => {
162
- if (installed !== restore) return
110
+ if (installed !== entry) return
163
111
  installed = undefined
164
- for (const [name, descriptor] of saved) {
165
- if (!descriptorsMatch(Object.getOwnPropertyDescriptor(service, name), written.get(name))) continue
166
- if (descriptor === undefined) delete service[name]
167
- else Object.defineProperty(service, name, descriptor)
168
- }
169
112
  }
170
- installed = restore
171
113
  return restore
172
114
  },
115
+ retire() {
116
+ available = false
117
+ installed = undefined
118
+ sourceListeners.clear()
119
+ },
173
120
  })
121
+ return seam
122
+ }
123
+
124
+ function registeredExecutionSeam(service, serviceName) {
125
+ const seam = createExecutionSeam(service, serviceName)
126
+ if (typeof service[PLUGIN_EXECUTION_SEAM] !== 'function') {
127
+ throw new Error(`ptc-plus: ${serviceName} requires the public execution-provider bundle composition`)
128
+ }
129
+ return seam
174
130
  }
175
131
 
176
132
  /**
@@ -259,7 +215,7 @@ export function installExecutionSeam(ctx, {
259
215
  entry = { name, scope }
260
216
  live = entry
261
217
  registerLifecycle(entry, name, scope)
262
- const attached = attach(scope, createExecutionSeam(scope?.[name] ?? readService(ctx, name), name))
218
+ const attached = attach(scope, registeredExecutionSeam(scope?.[name] ?? readService(ctx, name), name))
263
219
  entry.attached = attached
264
220
  return attached
265
221
  })
@@ -292,7 +248,7 @@ export function installExecutionSeam(ctx, {
292
248
  const entry = { name, scope }
293
249
  live = entry
294
250
  try {
295
- entry.attached = attach(scope, createExecutionSeam(scope?.[name] ?? readService(ctx, name), name))
251
+ entry.attached = attach(scope, registeredExecutionSeam(scope?.[name] ?? readService(ctx, name), name))
296
252
  } catch (error) {
297
253
  live = undefined
298
254
  throw error
@@ -15,6 +15,17 @@ const ISOLATED_STOP_MS = 5000
15
15
  /** Bound on the helper's output pipes really ending after its process exited. */
16
16
  const ISOLATED_OUTPUT_MS = 5000
17
17
 
18
+ export function isolatedWorkerEntry(entry) {
19
+ if (!(entry instanceof URL) && (typeof entry !== 'string' || entry.length === 0)) {
20
+ throw new TypeError('isolated worker entry must be a URL or a non-empty string filename')
21
+ }
22
+ return entry instanceof URL
23
+ ? entry.href
24
+ : entry.slice(0, 5).toLowerCase() === 'file:'
25
+ ? new URL(entry).href
26
+ : pathToFileURL(resolve(entry)).href
27
+ }
28
+
18
29
  /**
19
30
  * A worker-shaped transport whose worker lives in a killable helper process.
20
31
  *
@@ -29,17 +40,10 @@ export class IsolatedWorker extends EventEmitter {
29
40
  super()
30
41
  // The entry follows the worker constructor's contract: a URL or a string
31
42
  // filename. Anything else fails here, before a helper process exists.
32
- if (!(entry instanceof URL) && (typeof entry !== 'string' || entry.length === 0)) {
33
- throw new TypeError('isolated worker entry must be a URL or a non-empty string filename')
34
- }
35
43
  // The init message crosses advanced IPC, which cannot rebuild a URL object, so
36
44
  // the boundary normalizes it to one absolute file URL string. A path follows
37
45
  // the worker constructor's rule: resolved from the current working directory.
38
- const entrySpec = entry instanceof URL
39
- ? entry.href
40
- : entry.slice(0, 5).toLowerCase() === 'file:'
41
- ? new URL(entry).href
42
- : pathToFileURL(resolve(entry)).href
46
+ const entrySpec = isolatedWorkerEntry(entry)
43
47
  this.stdout = new PassThrough()
44
48
  this.stderr = new PassThrough()
45
49
  this.protocol = protocol