@plurnk/plurnk-service 1.3.2 → 1.3.4

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 (188) hide show
  1. package/.env.defaults +16 -3
  2. package/INSTALL.md +7 -3
  3. package/README.md +9 -0
  4. package/SPEC.md +82 -34
  5. package/dist/build-info.json +1 -1
  6. package/dist/content/edit-receipt.d.ts +35 -0
  7. package/dist/content/edit-receipt.d.ts.map +1 -0
  8. package/dist/content/edit-receipt.js +158 -0
  9. package/dist/content/edit-receipt.js.map +1 -0
  10. package/dist/content/index.d.ts +2 -0
  11. package/dist/content/index.d.ts.map +1 -1
  12. package/dist/content/index.js +1 -0
  13. package/dist/content/index.js.map +1 -1
  14. package/dist/content/line-marker.d.ts +3 -1
  15. package/dist/content/line-marker.d.ts.map +1 -1
  16. package/dist/content/line-marker.js +2 -0
  17. package/dist/content/line-marker.js.map +1 -1
  18. package/dist/core/ChannelWrite.d.ts +2 -1
  19. package/dist/core/ChannelWrite.d.ts.map +1 -1
  20. package/dist/core/ChannelWrite.js +7 -6
  21. package/dist/core/ChannelWrite.js.map +1 -1
  22. package/dist/core/CoreSchemeServices.d.ts +31 -0
  23. package/dist/core/CoreSchemeServices.d.ts.map +1 -0
  24. package/dist/core/CoreSchemeServices.js +37 -0
  25. package/dist/core/CoreSchemeServices.js.map +1 -0
  26. package/dist/core/Db.d.ts +2 -13
  27. package/dist/core/Db.d.ts.map +1 -1
  28. package/dist/core/Db.js +0 -5
  29. package/dist/core/Db.js.map +1 -1
  30. package/dist/core/Dispatcher.d.ts +7 -3
  31. package/dist/core/Dispatcher.d.ts.map +1 -1
  32. package/dist/core/Dispatcher.js +266 -70
  33. package/dist/core/Dispatcher.js.map +1 -1
  34. package/dist/core/Engine.d.ts +4 -2
  35. package/dist/core/Engine.d.ts.map +1 -1
  36. package/dist/core/Engine.js +55 -37
  37. package/dist/core/Engine.js.map +1 -1
  38. package/dist/core/Engine.sql +26 -40
  39. package/dist/core/LiveSubscriptions.d.ts +8 -0
  40. package/dist/core/LiveSubscriptions.d.ts.map +1 -0
  41. package/dist/core/LiveSubscriptions.js +32 -0
  42. package/dist/core/LiveSubscriptions.js.map +1 -0
  43. package/dist/core/LoopLifecycle.d.ts +20 -0
  44. package/dist/core/LoopLifecycle.d.ts.map +1 -0
  45. package/dist/core/LoopLifecycle.js +56 -0
  46. package/dist/core/LoopLifecycle.js.map +1 -0
  47. package/dist/core/LoopLifecycle.sql +60 -0
  48. package/dist/core/Owner.d.ts.map +1 -1
  49. package/dist/core/Owner.js.map +1 -1
  50. package/dist/core/PacketBuilder.d.ts.map +1 -1
  51. package/dist/core/PacketBuilder.js.map +1 -1
  52. package/dist/core/ProposalLifecycle.d.ts +4 -1
  53. package/dist/core/ProposalLifecycle.d.ts.map +1 -1
  54. package/dist/core/ProposalLifecycle.js +12 -6
  55. package/dist/core/ProposalLifecycle.js.map +1 -1
  56. package/dist/core/ProviderInstantiate.d.ts.map +1 -1
  57. package/dist/core/ProviderInstantiate.js +10 -22
  58. package/dist/core/ProviderInstantiate.js.map +1 -1
  59. package/dist/core/SchemeRegistry.d.ts +3 -0
  60. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  61. package/dist/core/SchemeRegistry.js +23 -0
  62. package/dist/core/SchemeRegistry.js.map +1 -1
  63. package/dist/core/TelemetryChannel.d.ts.map +1 -1
  64. package/dist/core/TelemetryChannel.js.map +1 -1
  65. package/dist/core/caps/CapsResolve.d.ts.map +1 -1
  66. package/dist/core/caps/CapsResolve.js.map +1 -1
  67. package/dist/core/caps/DbChannelCaps.d.ts.map +1 -1
  68. package/dist/core/caps/DbChannelCaps.js.map +1 -1
  69. package/dist/core/caps/DbEntryCaps.d.ts.map +1 -1
  70. package/dist/core/caps/DbEntryCaps.js +8 -4
  71. package/dist/core/caps/DbEntryCaps.js.map +1 -1
  72. package/dist/core/caps/DbSubscriptionCaps.d.ts +3 -2
  73. package/dist/core/caps/DbSubscriptionCaps.d.ts.map +1 -1
  74. package/dist/core/caps/DbSubscriptionCaps.js +20 -9
  75. package/dist/core/caps/DbSubscriptionCaps.js.map +1 -1
  76. package/dist/core/caps/DbTagCaps.d.ts.map +1 -1
  77. package/dist/core/caps/DbTagCaps.js.map +1 -1
  78. package/dist/core/caps/SchemeCtxImpl.d.ts +3 -16
  79. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
  80. package/dist/core/caps/SchemeCtxImpl.js +4 -23
  81. package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
  82. package/dist/core/fork.d.ts.map +1 -1
  83. package/dist/core/fork.js.map +1 -1
  84. package/dist/core/git-iso.d.ts +3 -0
  85. package/dist/core/git-iso.d.ts.map +1 -1
  86. package/dist/core/git-iso.js +35 -4
  87. package/dist/core/git-iso.js.map +1 -1
  88. package/dist/core/git-membership.d.ts.map +1 -1
  89. package/dist/core/git-membership.js.map +1 -1
  90. package/dist/core/git-state.d.ts.map +1 -1
  91. package/dist/core/git-state.js +3 -6
  92. package/dist/core/git-state.js.map +1 -1
  93. package/dist/core/packet-wire.d.ts.map +1 -1
  94. package/dist/core/packet-wire.js +37 -11
  95. package/dist/core/packet-wire.js.map +1 -1
  96. package/dist/core/run-ops.sql +1 -1
  97. package/dist/core/scheme-types.d.ts.map +1 -1
  98. package/dist/core/scheme-types.js +3 -4
  99. package/dist/core/scheme-types.js.map +1 -1
  100. package/dist/core/turn-scheduler.d.ts +3 -0
  101. package/dist/core/turn-scheduler.d.ts.map +1 -0
  102. package/dist/core/turn-scheduler.js +20 -0
  103. package/dist/core/turn-scheduler.js.map +1 -0
  104. package/dist/core/worker-cap.d.ts.map +1 -1
  105. package/dist/core/worker-cap.js.map +1 -1
  106. package/dist/core/workspace-settings.d.ts.map +1 -1
  107. package/dist/core/workspace-settings.js.map +1 -1
  108. package/dist/core/world-state.d.ts.map +1 -1
  109. package/dist/core/world-state.js.map +1 -1
  110. package/dist/digest/Digest.d.ts.map +1 -1
  111. package/dist/digest/Digest.js +4 -6
  112. package/dist/digest/Digest.js.map +1 -1
  113. package/dist/schemes/Exec.d.ts +11 -10
  114. package/dist/schemes/Exec.d.ts.map +1 -1
  115. package/dist/schemes/Exec.js +45 -38
  116. package/dist/schemes/Exec.js.map +1 -1
  117. package/dist/schemes/ExecOutputScheme.d.ts +8 -6
  118. package/dist/schemes/ExecOutputScheme.d.ts.map +1 -1
  119. package/dist/schemes/ExecOutputScheme.js +11 -6
  120. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  121. package/dist/schemes/File.d.ts +16 -9
  122. package/dist/schemes/File.d.ts.map +1 -1
  123. package/dist/schemes/File.js +75 -26
  124. package/dist/schemes/File.js.map +1 -1
  125. package/dist/schemes/Log.d.ts +9 -7
  126. package/dist/schemes/Log.d.ts.map +1 -1
  127. package/dist/schemes/Log.js +21 -9
  128. package/dist/schemes/Log.js.map +1 -1
  129. package/dist/schemes/Prompt.d.ts +1 -4
  130. package/dist/schemes/Prompt.d.ts.map +1 -1
  131. package/dist/schemes/Prompt.js +2 -7
  132. package/dist/schemes/Prompt.js.map +1 -1
  133. package/dist/schemes/Skill.d.ts +1 -5
  134. package/dist/schemes/Skill.d.ts.map +1 -1
  135. package/dist/schemes/Skill.js +4 -11
  136. package/dist/schemes/Skill.js.map +1 -1
  137. package/dist/schemes/Worker.d.ts +15 -10
  138. package/dist/schemes/Worker.d.ts.map +1 -1
  139. package/dist/schemes/Worker.js +40 -29
  140. package/dist/schemes/Worker.js.map +1 -1
  141. package/dist/schemes/_entry-crud.d.ts.map +1 -1
  142. package/dist/schemes/_entry-crud.js.map +1 -1
  143. package/dist/schemes/_entry-find.d.ts +8 -8
  144. package/dist/schemes/_entry-find.d.ts.map +1 -1
  145. package/dist/schemes/_entry-find.js +76 -23
  146. package/dist/schemes/_entry-find.js.map +1 -1
  147. package/dist/schemes/_entry-find.sql +20 -0
  148. package/dist/schemes/_entry-graph.d.ts.map +1 -1
  149. package/dist/schemes/_entry-graph.js.map +1 -1
  150. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  151. package/dist/schemes/_entry-manifest.js.map +1 -1
  152. package/dist/schemes/_entry-ops.d.ts +3 -2
  153. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  154. package/dist/schemes/_entry-ops.js +41 -13
  155. package/dist/schemes/_entry-ops.js.map +1 -1
  156. package/dist/schemes/_entry-semantic.d.ts +2 -1
  157. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  158. package/dist/schemes/_entry-semantic.js +15 -4
  159. package/dist/schemes/_entry-semantic.js.map +1 -1
  160. package/dist/schemes/_entry-semantic.sql +3 -0
  161. package/dist/schemes/_entry-send.d.ts.map +1 -1
  162. package/dist/schemes/_entry-send.js.map +1 -1
  163. package/dist/server/Daemon.d.ts +9 -1
  164. package/dist/server/Daemon.d.ts.map +1 -1
  165. package/dist/server/Daemon.js +161 -74
  166. package/dist/server/Daemon.js.map +1 -1
  167. package/dist/server/DaemonLock.d.ts +7 -0
  168. package/dist/server/DaemonLock.d.ts.map +1 -0
  169. package/dist/server/DaemonLock.js +85 -0
  170. package/dist/server/DaemonLock.js.map +1 -0
  171. package/dist/server/clientTurn.d.ts.map +1 -1
  172. package/dist/server/clientTurn.js +2 -3
  173. package/dist/server/clientTurn.js.map +1 -1
  174. package/dist/server/dispatch-as-plurnk.d.ts.map +1 -1
  175. package/dist/server/dispatch-as-plurnk.js +20 -2
  176. package/dist/server/dispatch-as-plurnk.js.map +1 -1
  177. package/dist/server/drain.sql +4 -8
  178. package/dist/server/envelope.d.ts.map +1 -1
  179. package/dist/server/envelope.js +2 -3
  180. package/dist/server/envelope.js.map +1 -1
  181. package/dist/server/lifecycle-recovery.sql +68 -0
  182. package/dist/server/logEntry.d.ts.map +1 -1
  183. package/dist/server/logEntry.js.map +1 -1
  184. package/dist/service.d.ts.map +1 -1
  185. package/dist/service.js +61 -40
  186. package/dist/service.js.map +1 -1
  187. package/migrations/0000-00-00.01_schema.sql +5 -5
  188. package/package.json +21 -18
@@ -7,12 +7,14 @@ import WorkerCap from "./worker-cap.js";
7
7
  import { decodePathParens } from "./path-decode.js";
8
8
  import Namespace from "./namespace.js";
9
9
  import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
10
+ import ChannelWrite from "./ChannelWrite.js";
10
11
  import { LineMarkerOps, MimetypeBinary } from "../content/index.js";
11
12
  import SchemeCtxImpl from "./caps/SchemeCtxImpl.js";
12
13
  import EntryCrud from "../schemes/_entry-crud.js";
13
14
  import EntryFind from "../schemes/_entry-find.js";
14
15
  import EntryOps from "../schemes/_entry-ops.js";
15
16
  import WorkspaceSettings from "./workspace-settings.js";
17
+ import LoopLifecycle from "./LoopLifecycle.js";
16
18
  // SPEC §scheme-surface: writer must be in target scheme's manifest.writableBy.
17
19
  // OPEN/FOLD/READ/FIND are not gated — they curate the log or read, never mutating an entry.
18
20
  const MUTATING_OPS = new Set(["EDIT", "SEND", "COPY", "MOVE", "EXEC", "KILL", "FORK", "WORK"]);
@@ -39,6 +41,7 @@ class Dispatcher {
39
41
  #wakeWorkerNotify;
40
42
  #injectWorker;
41
43
  #cancelWorker;
44
+ #cancelDescendants;
42
45
  // §send-premature-terminate/[102]<T> — the engine-owned park-deadline registry (loopId → seconds;
43
46
  // -1 = indefinite). The dispatcher WRITES at park; the daemon's drain park-exit consumes.
44
47
  #parkDeadlines;
@@ -46,7 +49,10 @@ class Dispatcher {
46
49
  // §join-blocking-collect (#354) — loops with a READ(worker://running-child) armed this turn; a bare
47
50
  // SEND[102] parks on it (blocking join). Engine-owned, twin of #parkDeadlines.
48
51
  #joinTargets;
49
- constructor({ db, schemes, mimetypes, tokenize, telemetry, proposals, executors, loopSignal, streamEventNotify, wakeWorkerNotify, injectWorker, cancelWorker, searchGate, parkDeadlines, joinTargets }) {
52
+ #liveSubscriptions;
53
+ #lifecycle;
54
+ #preparedEdits = new WeakMap();
55
+ constructor({ db, schemes, mimetypes, tokenize, telemetry, proposals, executors, loopSignal, streamEventNotify, wakeWorkerNotify, injectWorker, cancelWorker, cancelDescendants, searchGate, parkDeadlines, joinTargets, liveSubscriptions }) {
50
56
  this.#db = db;
51
57
  this.#schemes = schemes;
52
58
  this.#mimetypes = mimetypes;
@@ -59,13 +65,58 @@ class Dispatcher {
59
65
  this.#wakeWorkerNotify = wakeWorkerNotify;
60
66
  this.#injectWorker = injectWorker;
61
67
  this.#cancelWorker = cancelWorker;
68
+ this.#cancelDescendants = cancelDescendants;
62
69
  this.#parkDeadlines = parkDeadlines ?? new Map();
63
70
  this.#searchGate = searchGate;
64
71
  this.#joinTargets = joinTargets ?? new Set();
72
+ this.#liveSubscriptions = liveSubscriptions;
73
+ this.#lifecycle = new LoopLifecycle(db);
65
74
  }
66
75
  // workspace → project_root, memoized: {§fs-namespace} fixes the root immutably at
67
76
  // workspace creation, so a process-lifetime cache can never go stale.
68
77
  #rootCache = new Map();
78
+ #handlerContext(scheme, ctx) {
79
+ const manifest = this.#schemes.manifestFor(scheme);
80
+ return manifest === undefined ? null : new SchemeCtxImpl(ctx, scheme, manifest, this.#liveSubscriptions);
81
+ }
82
+ #entryContext(scheme, ctx) {
83
+ const handlerCtx = this.#handlerContext(scheme, ctx);
84
+ return this.#schemes.manifestFor(scheme)?.category === "data" ? handlerCtx : null;
85
+ }
86
+ async #readEntry(scheme, pathname, ctx) {
87
+ const handler = this.#schemes.get(scheme);
88
+ const handlerCtx = this.#entryContext(scheme, ctx);
89
+ if (typeof handler?.readEntry === "function" && handlerCtx !== null)
90
+ return handler.readEntry(pathname, handlerCtx);
91
+ const caps = handlerCtx?.entries;
92
+ if (caps === undefined)
93
+ return { status: 501, entry: null };
94
+ const result = await caps.read(pathname, scheme === "prompt" ? "worker" : "commons");
95
+ return {
96
+ status: result.status,
97
+ entry: result.entry === null
98
+ ? null
99
+ : { channels: { ...result.entry.channels }, tags: [...result.entry.tags] },
100
+ };
101
+ }
102
+ async #writeEntry(scheme, pathname, entry, ctx) {
103
+ const handler = this.#schemes.get(scheme);
104
+ const handlerCtx = this.#entryContext(scheme, ctx);
105
+ if (typeof handler?.writeEntry === "function" && handlerCtx !== null)
106
+ return handler.writeEntry(pathname, entry, handlerCtx);
107
+ const caps = handlerCtx?.entries;
108
+ if (caps === undefined)
109
+ return { status: 501, created: false, entryId: null };
110
+ return caps.write(pathname, entry);
111
+ }
112
+ async #deleteEntry(scheme, pathname, ctx) {
113
+ const handler = this.#schemes.get(scheme);
114
+ const handlerCtx = this.#entryContext(scheme, ctx);
115
+ if (typeof handler?.deleteEntry === "function" && handlerCtx !== null)
116
+ return handler.deleteEntry(pathname, handlerCtx);
117
+ const caps = handlerCtx?.entries;
118
+ return caps === undefined ? { status: 501 } : caps.delete(pathname);
119
+ }
69
120
  async #workspaceRoot(workspaceId) {
70
121
  if (this.#rootCache.has(workspaceId))
71
122
  return this.#rootCache.get(workspaceId) ?? null;
@@ -85,7 +136,86 @@ class Dispatcher {
85
136
  if (key !== null)
86
137
  target.pathname = key;
87
138
  }
139
+ async prepareEditBatches(statements, context) {
140
+ const { workspaceId, workerId, loopId, turnId, origin } = context;
141
+ const schemeCtx = this.#buildSchemeCtx({ workspaceId, workerId, loopId, turnId, origin });
142
+ const groups = new Map();
143
+ for (const statement of statements) {
144
+ const target = this.#extractTarget(statement.target);
145
+ await this.#canonColumns(target, workspaceId);
146
+ const key = JSON.stringify([
147
+ schemeNameOf(statement.target),
148
+ target.scheme,
149
+ target.hostname,
150
+ target.pathname,
151
+ target.fragment,
152
+ ]);
153
+ const group = groups.get(key);
154
+ if (group === undefined)
155
+ groups.set(key, [statement]);
156
+ else
157
+ group.push(statement);
158
+ }
159
+ for (const group of groups.values()) {
160
+ const first = group[0];
161
+ const schemeName = schemeNameOf(first.target);
162
+ let initial;
163
+ let denial = group.map((statement) => this.#checkWritable(statement, origin)).find((result) => result !== null) ?? null;
164
+ if (denial === null) {
165
+ for (const statement of group) {
166
+ denial = await this.#checkFlagsGate(statement, loopId);
167
+ if (denial !== null)
168
+ break;
169
+ }
170
+ }
171
+ if (denial !== null) {
172
+ initial = denial;
173
+ }
174
+ else if (schemeName === null) {
175
+ initial = { status: 400 };
176
+ }
177
+ else {
178
+ const handler = this.#schemes.get(schemeName);
179
+ const method = handler?.editBatch;
180
+ const manifest = this.#schemes.manifestFor(schemeName);
181
+ if (typeof method !== "function" || manifest === undefined) {
182
+ initial = { status: 501 };
183
+ }
184
+ else {
185
+ try {
186
+ const addressedScheme = first.target?.kind === "url" ? first.target.scheme : schemeName;
187
+ initial = await method.call(handler, group, new SchemeCtxImpl(schemeCtx, addressedScheme ?? schemeName, manifest, this.#liveSubscriptions));
188
+ }
189
+ catch (err) {
190
+ initial = { status: 500, error: err instanceof Error ? err.message : String(err) };
191
+ }
192
+ }
193
+ }
194
+ let resolveSettled;
195
+ const settled = new Promise((resolve) => { resolveSettled = resolve; });
196
+ const prepared = {
197
+ first: true,
198
+ index: 0,
199
+ initial,
200
+ settled,
201
+ settle: resolveSettled,
202
+ };
203
+ this.#preparedEdits.set(first, prepared);
204
+ for (const [index, statement] of group.slice(1).entries()) {
205
+ this.#preparedEdits.set(statement, { ...prepared, first: false, index: index + 1 });
206
+ }
207
+ }
208
+ }
88
209
  async dispatch(context) {
210
+ const result = await this.#dispatchOne(context);
211
+ if (context.statement.op === "EDIT") {
212
+ const prepared = this.#preparedEdits.get(context.statement);
213
+ if (prepared?.first === true)
214
+ prepared.settle(result);
215
+ }
216
+ return result;
217
+ }
218
+ async #dispatchOne(context) {
89
219
  const { statement, workspaceId, workerId, loopId, turnId, sequence, origin, onDispatch, turnParseErrors } = context;
90
220
  const schemeCtx = this.#buildSchemeCtx({ workspaceId, workerId, loopId, turnId, origin });
91
221
  let result;
@@ -108,7 +238,37 @@ class Dispatcher {
108
238
  // skips it. Logging failures (#writeLog throws) are NOT caught —
109
239
  // those are system failures.
110
240
  try {
111
- if (statement.op === "SEND" && statement.target === null) {
241
+ if (statement.op === "EDIT") {
242
+ const prepared = this.#preparedEdits.get(statement);
243
+ if (prepared === undefined) {
244
+ result = { status: 500, error: "EDIT reached dispatch without a prepared resource batch" };
245
+ }
246
+ else {
247
+ const settled = prepared.first ? prepared.initial : await prepared.settled;
248
+ const aggregate = settled.editReceipt ?? prepared.initial.editReceipt;
249
+ if (aggregate !== null && typeof aggregate === "object") {
250
+ const receipt = aggregate;
251
+ const effect = receipt.effects[prepared.index];
252
+ if (effect === undefined)
253
+ throw new Error(`EDIT receipt has no effect at index ${prepared.index}`);
254
+ const { editReceipt: _editReceipt, ...withoutAggregate } = settled;
255
+ result = {
256
+ ...withoutAggregate,
257
+ receipt: {
258
+ revision: receipt.revision,
259
+ unit: receipt.unit,
260
+ before: receipt.before,
261
+ after: receipt.after,
262
+ effect,
263
+ },
264
+ };
265
+ }
266
+ else {
267
+ result = settled;
268
+ }
269
+ }
270
+ }
271
+ else if (statement.op === "SEND" && statement.target === null) {
112
272
  result = await this.#handleSendBroadcast(statement, { workspaceId, workerId, loopId, turnId, turnParseErrors });
113
273
  }
114
274
  else if (statement.op === "FORK" || statement.op === "WORK") {
@@ -216,12 +376,13 @@ class Dispatcher {
216
376
  const moveSource = result.attrs?.moveSource;
217
377
  if (moveSource !== undefined) {
218
378
  const srcHandler = this.#schemes.get(moveSource.scheme);
219
- if (srcHandler !== undefined && typeof srcHandler.deleteEntry === "function") {
220
- const del = await srcHandler.deleteEntry(moveSource.pathname, schemeCtx);
379
+ if (srcHandler !== undefined) {
380
+ const del = await this.#deleteEntry(moveSource.scheme, moveSource.pathname, schemeCtx);
221
381
  // A host-effecting source-delete PROPOSES (202); the MOVE proposal already gated the whole
222
382
  // create+kill, so apply the source-delete now — never raise a second review for one MOVE.
223
- if (del.status === 202 && del.attrs !== undefined && typeof srcHandler.applyResolution === "function")
224
- await srcHandler.applyResolution({ attrs: del.attrs }, schemeCtx);
383
+ const sourceCtx = this.#handlerContext(moveSource.scheme, schemeCtx);
384
+ if (del.status === 202 && del.attrs !== undefined && typeof srcHandler.applyResolution === "function" && sourceCtx !== null)
385
+ await srcHandler.applyResolution({ attrs: del.attrs }, sourceCtx);
225
386
  }
226
387
  }
227
388
  }
@@ -501,8 +662,7 @@ class Dispatcher {
501
662
  const srcSchemeName = schemeNameOf(srcPath);
502
663
  if (srcSchemeName === null)
503
664
  return { status: 400, error: "MOVE source must be a URL path with a scheme" };
504
- const srcHandler = this.#schemes.get(srcSchemeName);
505
- if (srcHandler === undefined || typeof srcHandler.deleteEntry !== "function")
665
+ if (!this.#schemes.has(srcSchemeName))
506
666
  return { status: 501 };
507
667
  // Relocation: COPY then DELETE source (§move-relocation-deletes-source).
508
668
  const copyResult = await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
@@ -516,7 +676,7 @@ class Dispatcher {
516
676
  if (copyResult.status === 202) {
517
677
  return { ...copyResult, attrs: { ...copyResult.attrs, moveSource: { scheme: srcSchemeName, pathname: srcPathname } } };
518
678
  }
519
- const delResult = await srcHandler.deleteEntry(srcPathname, ctx);
679
+ const delResult = await this.#deleteEntry(srcSchemeName, srcPathname, ctx);
520
680
  if (delResult.status >= 400)
521
681
  return { status: delResult.status };
522
682
  return copyResult;
@@ -549,7 +709,8 @@ class Dispatcher {
549
709
  if (killable !== undefined && typeof killable.kill === "function") {
550
710
  // Pass the model's OWN scheme so a stream-KILL error answers in the runtime tag the
551
711
  // model addressed (sh:///…), not the internal `exec` ({§fs-answer-in-canon}).
552
- return await killable.kill(pathnameFromPath(path), statement.signal, ctx, schemeName);
712
+ const handlerCtx = this.#handlerContext(schemeName, ctx);
713
+ return handlerCtx === null ? { status: 501 } : await killable.kill(pathnameFromPath(path), statement.signal, handlerCtx, schemeName);
553
714
  }
554
715
  if (schemeName === "worker") {
555
716
  // Entry-path present → KILL a worker-scope scratch ENTRY (delete it), self-only —
@@ -558,11 +719,11 @@ class Dispatcher {
558
719
  const entryPath = path.kind === "url" ? (path.pathname ?? "") : "";
559
720
  if (entryPath !== "" && entryPath !== "/") {
560
721
  const workerHandler = this.#schemes.get("worker");
561
- return await workerHandler.killEntry(statement, ctx);
722
+ const handlerCtx = this.#handlerContext("worker", ctx);
723
+ return handlerCtx === null ? { status: 501 } : await workerHandler.killEntry(statement, handlerCtx);
562
724
  }
563
- // terminate abort any worker by address; whoever holds it may end it.
564
- // `worker://self` = self. cancelWorker (→ Daemon.cancelDrain) aborts the worker's signal
565
- // (its loop closes 499); an idle run is a no-op-200, a missing run 404.
725
+ // Terminate an addressed worker's structured scope. `worker://self` = self.
726
+ // An idle worker is a no-op 200; a missing worker is 404.
566
727
  const name = path.kind === "url" ? (path.hostname ?? "") : ""; // §worker-scheme — the worker is the AUTHORITY
567
728
  if (name === "")
568
729
  return { status: 400, error: "worker:// kill requires a worker name or ~ (worker://<name>)" };
@@ -575,21 +736,16 @@ class Dispatcher {
575
736
  }
576
737
  if (this.#cancelWorker === undefined)
577
738
  throw new Error("run kill: cancelWorker capability absent");
578
- // §op-synchronous — KILL is DECISIVE: flip the worker's live loops to 499 NOW so the
579
- // same-turn premature-terminate gate sees it dead (KILL SEND[200] concludes in ONE
580
- // turn the model never reasons about async reap timing). The physical scope reap
581
- // (drain abort + stream teardown) then rides cancelWorker; a killed loop can't heal back
582
- // because cancelWorker aborts its drain's signal.
583
- await this.#db.engine_terminate_worker_live_loops.run({ worker_id: workerId, message: "killed via worker:// KILL" });
584
- this.#cancelWorker(workerId);
739
+ // §op-synchronous — KILL is decisive. Await the one lifecycle owner so the
740
+ // same-turn pending-work gate observes the complete subtree as terminal.
741
+ await this.#cancelWorker(workerId, "killed via worker:// KILL");
585
742
  return { status: 200 };
586
743
  }
587
- const handler = this.#schemes.get(schemeName);
588
- if (handler === undefined || typeof handler.deleteEntry !== "function")
744
+ if (!this.#schemes.has(schemeName))
589
745
  return { status: 501 };
590
746
  // A host-effecting delete (file) returns 202 to PROPOSE — pass its attrs through so the proposal
591
747
  // carries the delete target to review (§isProposal fires on 202). Plurnk-internal deletes execute inline.
592
- const delResult = await handler.deleteEntry(pathnameFromPath(path), ctx);
748
+ const delResult = await this.#deleteEntry(schemeName, pathnameFromPath(path), ctx);
593
749
  return delResult.attrs !== undefined ? { status: delResult.status, attrs: delResult.attrs } : { status: delResult.status };
594
750
  }
595
751
  // Multi-file READ fan-out (SPEC §matcher-result — "the companion to FIND's survey"). A glob
@@ -613,15 +769,15 @@ class Dispatcher {
613
769
  const schemeName = schemeNameOf(t);
614
770
  return p.endsWith("/") && schemeName !== null && this.#schemes.manifestFor(schemeName)?.folderScopes === true;
615
771
  }
616
- // Clone the READ onto one concrete match — the FIND already matched, so the per-match READ
617
- // delivers content at the span: strip the body (no re-match) and set <L> to the span. A null
618
- // span (body-less folder/glob fan-out) reads the whole entry. #286
772
+ // Clone the READ onto one concrete match — the FIND already matched, so strip the body
773
+ // (no re-match) and set <L> to its readable-row span. A null span (body-less folder/glob
774
+ // fan-out) reads the whole entry. #286
619
775
  static #retargetRead(statement, pathname, span) {
620
776
  const t = statement.target;
621
777
  const target = t !== null && t.kind === "url"
622
778
  ? { ...t, pathname, raw: `${t.scheme}://${pathname}` }
623
779
  : { ...t, raw: pathname };
624
- const lineMarker = span !== null ? { marks: [span.lineStart, span.lineEnd] } : null;
780
+ const lineMarker = span !== null ? { marks: [span.rowStart, span.rowEnd] } : null;
625
781
  return { ...statement, target: target, lineMarker, body: null };
626
782
  }
627
783
  #standardEntryManifest(schemeName, statement) {
@@ -652,7 +808,7 @@ class Dispatcher {
652
808
  const displayPath = hostPrefix !== null && pathname.startsWith(`${hostPrefix}/`)
653
809
  ? pathname.slice(hostPrefix.length)
654
810
  : pathname;
655
- const lineMarker = span === null ? null : { marks: [span.lineStart, span.lineEnd] };
811
+ const lineMarker = span === null ? null : { marks: [span.rowStart, span.rowEnd] };
656
812
  return {
657
813
  ...statement,
658
814
  target: storage
@@ -695,10 +851,10 @@ class Dispatcher {
695
851
  onDispatch?.(id);
696
852
  return { ...result, rowsWritten: 1 };
697
853
  }
698
- // One READ row per MATCH — the span's source lines (or the whole entry for a body-less
699
- // folder/glob). The match span is SOURCE LINES, so deliver via a raw line-slice — NOT the
700
- // scheme's <L> (which is item-index for application/json, structural for xml). Read each
701
- // distinct entry's content once, then line-slice per match. #286
854
+ // One READ row per MATCH — deliver its source lines (or the whole entry for a body-less
855
+ // folder/glob) while the logged statement carries the corresponding readable-row scope.
856
+ // The delivery uses a raw line-slice rather than reinterpreting provenance as the scheme's
857
+ // structural <L>. Read each distinct entry's content once, then line-slice per match. #286
702
858
  // §matcher-selection-signal — the SELECTION SUMMARY row: the internal FIND that located
703
859
  // the matches is WRITTEN (op=FIND, its full result — per-hit matchSpan + matchPath, the
704
860
  // canonical dialect coordinate) before the per-match deliveries, exactly as if the model
@@ -791,20 +947,16 @@ class Dispatcher {
791
947
  const dstHandler = this.#schemes.get(dstSchemeName);
792
948
  if (srcHandler === undefined || dstHandler === undefined)
793
949
  return { status: 501 };
794
- if (typeof srcHandler.readEntry !== "function" || typeof dstHandler.writeEntry !== "function")
795
- return { status: 501 };
796
950
  const srcPathname = pathnameFromPath(srcPath);
797
951
  const dstPathname = pathnameFromPath(dstPath);
798
- const srcResult = await srcHandler.readEntry(srcPathname, ctx);
952
+ const srcResult = await this.#readEntry(srcSchemeName, srcPathname, ctx);
799
953
  if (srcResult.status !== 200 || srcResult.entry === null)
800
954
  return { status: 404, error: `COPY/MOVE source not found: ${srcSchemeName}://${srcPathname}` }; // §copy-missing-source-404 §move-missing-source-404
801
955
  const entry = srcResult.entry;
802
956
  // Destination read — the conflict/no-op verdict is deferred until the
803
957
  // to-be-written content is known (after <L> slice + tag resolution below),
804
958
  // so an identical re-copy resolves to 304 instead of a phantom 409.
805
- const dstExisting = typeof dstHandler.readEntry === "function"
806
- ? await dstHandler.readEntry(dstPathname, ctx)
807
- : null;
959
+ const dstExisting = await this.#readEntry(dstSchemeName, dstPathname, ctx);
808
960
  // Mimetype compatibility check against the destination scheme's manifest
809
961
  const dstManifest = dstHandler.constructor.manifest;
810
962
  const dstChannels = dstManifest?.channels ?? {};
@@ -852,7 +1004,7 @@ class Dispatcher {
852
1004
  return { status: 304 }; // identical → §copy-noop-304
853
1005
  return { status: 409, error: `COPY/MOVE destination exists: ${dstSchemeName}://${dstPathname}` }; // §copy-conflict-409
854
1006
  }
855
- const writeResult = await dstHandler.writeEntry(dstPathname, { channels, tags }, ctx);
1007
+ const writeResult = await this.#writeEntry(dstSchemeName, dstPathname, { channels, tags }, ctx);
856
1008
  // A file dest returns 202 (disk write → §membership review): propagate the
857
1009
  // proposal so dispatch runs the gate + routes applyResolution to the dest.
858
1010
  if (writeResult.status === 202)
@@ -874,16 +1026,34 @@ class Dispatcher {
874
1026
  const liveChild = await this.#db.engine_worker_has_live_child.get({ worker_id: workerId });
875
1027
  if (liveChild !== undefined)
876
1028
  pending.push("surviving workers");
877
- const retrievals = await this.#db.engine_turn_retrievals.all({ turn_id: turnId });
878
- if (retrievals.length > 0)
1029
+ const observations = await this.#pendingObservations(workerId, turnId);
1030
+ if (observations.retrievals)
879
1031
  pending.push("this turn's retrieval results (they land in the NEXT packet's Log)");
880
- // §send-undelivered-child-term — a worker that concluded DURING this turn's generation is no
881
- // longer "live" but its deliverable hasn't reached any packet yet; concluding discards it.
882
- const undelivered = await this.#db.engine_worker_has_undelivered_child_term.get({ worker_id: workerId, turn_id: turnId });
883
- if (undelivered !== undefined)
1032
+ if (observations.streamTerminations) {
1033
+ pending.push("completed stream results that land in the NEXT packet's Log");
1034
+ }
1035
+ if (observations.childTerminations)
884
1036
  pending.push("worker results that arrived during this turn (they land NEXT turn)");
885
1037
  return pending;
886
1038
  }
1039
+ // Results cross an observation boundary only when they have appeared in a
1040
+ // packet. Completion alone is not delivery. Keep the three next-packet
1041
+ // producers in one classifier so SEND[200]'s discard gate and SEND[202]'s
1042
+ // empty-join decision cannot disagree about what remains unseen.
1043
+ async #pendingObservations(workerId, turnId) {
1044
+ const [retrievals, streamTermination, childTermination] = await Promise.all([
1045
+ this.#db.engine_turn_retrievals.all({ turn_id: turnId }),
1046
+ this.#db.engine_worker_has_undelivered_stream_term
1047
+ .get({ worker_id: workerId }),
1048
+ this.#db.engine_worker_has_undelivered_child_term
1049
+ .get({ worker_id: workerId, turn_id: turnId }),
1050
+ ]);
1051
+ return {
1052
+ retrievals: retrievals.length > 0,
1053
+ streamTerminations: streamTermination !== undefined,
1054
+ childTerminations: childTermination !== undefined,
1055
+ };
1056
+ }
887
1057
  // J — a live obligation to WAIT on: a spawned child or an open stream (NOT retrievals, which land
888
1058
  // next turn regardless). The wait-side twin of #pendingSet's stream+child legs (§wait-obligation-matrix).
889
1059
  async #hasLiveWork(workerId) {
@@ -917,12 +1087,14 @@ class Dispatcher {
917
1087
  // terminal with a live child stays the existing premature-terminate steer (#354 decision #1).
918
1088
  const joinArmed = this.#joinTargets.delete(loopId);
919
1089
  if (status === 102 && statement.lineMarker === null && joinArmed) {
920
- await this.#db.engine_loop_set_status.run({ status: 202, loop_id: loopId, message: raw.length > 0 ? raw : "parked — awaiting a worker's result (blocking collect)" });
1090
+ if (!await this.#lifecycle.park(loopId, raw.length > 0 ? raw : "parked — awaiting a worker's result (blocking collect)")) {
1091
+ return { status: await this.#lifecycle.status(loopId) };
1092
+ }
921
1093
  this.#parkDeadlines.set(loopId, -1); // indefinite: the bounded child's terminal is the wake edge
922
1094
  return { status: 102, attrs: { parked: -1, join: true } };
923
1095
  }
924
- // §wait-obligation-matrix — the WAIT: SEND[202], and the legacy SEND[102]<T> the terminal
925
- // redesign spelled the same park with, are one obligation-checked wait (waitpid). A live
1096
+ // §wait-obligation-matrix — SEND[202] and the legacy SEND[102]<T> are one
1097
+ // obligation-checked join. A live
926
1098
  // obligation (a spawned child or open stream, J) BLOCKS the loop until it concludes and
927
1099
  // reawakens it (§worker-lifecycle-child-wake); a wait on nothing (∅) is already satisfied and
928
1100
  // resolves like 200, so <-1>+∅ self-resolves rather than hang the agent; a pending own
@@ -931,26 +1103,27 @@ class Dispatcher {
931
1103
  const marks = statement.lineMarker?.marks[0];
932
1104
  const seconds = typeof marks === "number" ? marks : -1; // bare 202 / absent T = indefinite, bounded by the join
933
1105
  if (await this.#hasLiveWork(workerId)) {
934
- await this.#db.engine_loop_set_status.run({ status: 202, loop_id: loopId, message: raw.length > 0 ? raw : "waiting on live work" });
1106
+ if (!await this.#lifecycle.park(loopId, raw.length > 0 ? raw : "waiting on live work")) {
1107
+ return { status: await this.#lifecycle.status(loopId) };
1108
+ }
935
1109
  this.#parkDeadlines.set(loopId, seconds);
936
1110
  return { status: 202, attrs: { waiting: seconds } };
937
1111
  }
938
- const retrievals = await this.#db.engine_turn_retrievals.all({ turn_id: turnId });
939
- if (retrievals.length > 0)
940
- return { status: 102 }; // R lands next turn continue, don't conclude over it
941
- // §send-undelivered-child-term — the fan-out race: a worker that concluded DURING this
942
- // turn's generation is not live (J misses it), but its collect delta is queued for the
943
- // NEXT build. The wait is NOT on nothing — the results are on the doorstep. Parking
944
- // would hang (the wake edges already fired into an unparked run); R semantics: continue,
945
- // the deltas land next turn, the model weighs them and concludes.
946
- const undelivered = await this.#db.engine_worker_has_undelivered_child_term.get({ worker_id: workerId, turn_id: turnId });
947
- if (undelivered !== undefined)
1112
+ // Retrievals, fast stream conclusions, and child conclusions are
1113
+ // all complete-but-unobserved. Their wake edge may already have
1114
+ // fired, so do not park; continue directly to the packet that
1115
+ // materializes them.
1116
+ const observations = await this.#pendingObservations(workerId, turnId);
1117
+ if (observations.retrievals || observations.streamTerminations || observations.childTerminations) {
948
1118
  return { status: 102 };
949
- // ∅ — a wait on nothing is a contradiction, the mirror of premature-terminate (owner
950
- // ruling, #502 run113): concluding it silently laundered the one signal that something
951
- // was structurally off (the model believed work was pending; nothing was). 409 gives
952
- // the turn back with the fact the recovery rail this exact confusion needs.
953
- return { status: 409, error: "Attempted [202] wait with nothing pending: no surviving streams, no worker runs, no unretrieved results." };
1119
+ }
1120
+ // The joined set is already drained. Awaiting an empty task group completes
1121
+ // immediately; it never parks and needs no corrective model turn.
1122
+ const finished = await this.#lifecycle.finish(loopId, 200, raw === "" ? null : raw);
1123
+ return {
1124
+ status: finished ? 200 : await this.#lifecycle.status(loopId),
1125
+ attrs: { joined: true, pending: 0 },
1126
+ };
954
1127
  }
955
1128
  // §send-300-choices — ask the operator and park (the answer returns via loop.inject).
956
1129
  // The three-state cascade (owner design): PLURNK_QUESTIONS unset = ALLOWED (not enabled);
@@ -975,7 +1148,7 @@ class Dispatcher {
975
1148
  }
976
1149
  // [200] — terminate, gated by the pending set (post-batch). The row records the refused
977
1150
  // attempt faithfully (status_rx=409, never erased); the loop stays a continue; the strike
978
- // couples in runTurn. [499] abandons regardless discard by stated intent.
1151
+ // couples in runTurn. [499] abandons and cancels the descendant scope.
979
1152
  if (status === 200) {
980
1153
  // §send-200-failed-ops (#363, owner ruling: never 200 over a failed op) — the failure
981
1154
  // twin of the pending set: this turn's failed op results (and this emission's parse
@@ -1008,11 +1181,14 @@ class Dispatcher {
1008
1181
  }
1009
1182
  return { status: 409, error: `Attempted [200] termination in a turn that performs retrieval or has surviving work: ${pending.join("; ")}. KILL what you no longer need; SEND[102] (or [102]<seconds>) to receive the rest; then conclude.` };
1010
1183
  }
1011
- await this.#db.engine_loop_set_status.run({ status: 200, loop_id: loopId, message: raw === "" ? null : raw });
1012
- return { status: 200 };
1184
+ const finished = await this.#lifecycle.finish(loopId, 200, raw === "" ? null : raw);
1185
+ return { status: finished ? 200 : await this.#lifecycle.status(loopId) };
1013
1186
  }
1014
1187
  if (status === 499) {
1015
- await this.#db.engine_loop_set_status.run({ status: 499, loop_id: loopId, message: raw === "" ? null : raw });
1188
+ const finished = await this.#lifecycle.finish(loopId, 499, raw === "" ? null : raw);
1189
+ if (!finished)
1190
+ return { status: await this.#lifecycle.status(loopId) };
1191
+ await this.#cancelDescendants?.(workerId, raw === "" ? "parent abandoned its scope" : raw);
1016
1192
  return { status: 499 };
1017
1193
  }
1018
1194
  // Every other signal — 102 bare, 202 (retired as a terminal; now ordinary mid-comms), 1xx —
@@ -1022,6 +1198,26 @@ class Dispatcher {
1022
1198
  async #run(schemeName, statement, ctx) {
1023
1199
  if (schemeName === null)
1024
1200
  return { status: 400 };
1201
+ if (statement.op === "SEND" && statement.signal === 499 && statement.target?.kind === "url") {
1202
+ const entry = await this.#db.crud_find_workspace_entry.get({
1203
+ workspace_id: ctx.workspaceId,
1204
+ owner_id: await Owner.commonsId(this.#db, ctx.workspaceId),
1205
+ scheme: schemeName,
1206
+ pathname: pathnameFromPath(statement.target),
1207
+ });
1208
+ if (entry !== undefined) {
1209
+ const subscription = await ChannelWrite.findActiveSubscription(this.#db, {
1210
+ workerId: ctx.workerId,
1211
+ entryId: entry.id,
1212
+ });
1213
+ if (subscription !== null && subscription.scheme === schemeName) {
1214
+ const cancelled = await this.#liveSubscriptions.cancel(subscription.id);
1215
+ return cancelled
1216
+ ? { status: 200 }
1217
+ : { status: 500, error: `subscription ${subscription.id} is durable but has no live cancellation handle` };
1218
+ }
1219
+ }
1220
+ }
1025
1221
  const handler = this.#schemes.get(schemeName);
1026
1222
  if (handler === undefined)
1027
1223
  return { status: 501 };
@@ -1033,7 +1229,7 @@ class Dispatcher {
1033
1229
  const manifest = this.#schemes.manifestFor(schemeName);
1034
1230
  if (manifest === undefined)
1035
1231
  throw new Error(`scheme '${schemeName}' has no manifest`);
1036
- return method.call(handler, statement, new SchemeCtxImpl(ctx, addressedScheme ?? schemeName, manifest));
1232
+ return method.call(handler, statement, new SchemeCtxImpl(ctx, addressedScheme ?? schemeName, manifest, this.#liveSubscriptions));
1037
1233
  }
1038
1234
  // A status-202 result is a reviewable PROPOSAL (a side-effecting op — EDIT/EXEC/
1039
1235
  // directed write — paused for client resolution) UNLESS it is a broadcast SEND.