@phyzess/pappra 0.0.0-stage → 0.1.0-beta.2

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 (221) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/integrations/skills/pappra/SKILL.md +68 -0
  5. package/assets/integrations/skills/pappra/references/hosts.md +30 -0
  6. package/assets/integrations/skills/pappra/references/lifecycle.md +37 -0
  7. package/assets/integrations/skills/pappra/references/operations.md +14 -0
  8. package/assets/manifest.json +4 -0
  9. package/dist/app/collaboration.d.ts +170 -0
  10. package/dist/app/collaboration.js +1650 -0
  11. package/dist/app/doctor.d.ts +9 -0
  12. package/dist/app/doctor.js +1030 -0
  13. package/dist/app/init.d.ts +143 -0
  14. package/dist/app/init.js +1111 -0
  15. package/dist/cli/commands/code.d.ts +16 -0
  16. package/dist/cli/commands/code.js +127 -0
  17. package/dist/cli/commands/collaboration.d.ts +13 -0
  18. package/dist/cli/commands/collaboration.js +186 -0
  19. package/dist/cli/commands/continuity.d.ts +29 -0
  20. package/dist/cli/commands/continuity.js +133 -0
  21. package/dist/cli/commands/docs.d.ts +11 -0
  22. package/dist/cli/commands/docs.js +133 -0
  23. package/dist/cli/commands/doctor.d.ts +14 -0
  24. package/dist/cli/commands/doctor.js +57 -0
  25. package/dist/cli/commands/host.d.ts +13 -0
  26. package/dist/cli/commands/host.js +125 -0
  27. package/dist/cli/commands/init.d.ts +51 -0
  28. package/dist/cli/commands/init.js +668 -0
  29. package/dist/cli/main.d.ts +2 -0
  30. package/dist/cli/main.js +10 -0
  31. package/dist/cli/output.d.ts +4 -0
  32. package/dist/cli/output.js +1 -0
  33. package/dist/cli/run.d.ts +2 -0
  34. package/dist/cli/run.js +597 -0
  35. package/dist/code/budget.d.ts +14 -0
  36. package/dist/code/budget.js +39 -0
  37. package/dist/code/exec.d.ts +23 -0
  38. package/dist/code/exec.js +133 -0
  39. package/dist/code/identity.d.ts +13 -0
  40. package/dist/code/identity.js +214 -0
  41. package/dist/code/index.d.ts +6 -0
  42. package/dist/code/index.js +3 -0
  43. package/dist/code/providers/codegraph.d.ts +59 -0
  44. package/dist/code/providers/codegraph.js +567 -0
  45. package/dist/code/registration.d.ts +9 -0
  46. package/dist/code/registration.js +293 -0
  47. package/dist/code/service.d.ts +53 -0
  48. package/dist/code/service.js +633 -0
  49. package/dist/code/text.d.ts +3 -0
  50. package/dist/code/text.js +202 -0
  51. package/dist/collab/admission.d.ts +450 -0
  52. package/dist/collab/admission.js +797 -0
  53. package/dist/collab/canonical.d.ts +26 -0
  54. package/dist/collab/canonical.js +136 -0
  55. package/dist/collab/contracts.d.ts +73 -0
  56. package/dist/collab/contracts.js +76 -0
  57. package/dist/collab/envelope.d.ts +52 -0
  58. package/dist/collab/envelope.js +241 -0
  59. package/dist/collab/fingerprint.d.ts +7 -0
  60. package/dist/collab/fingerprint.js +58 -0
  61. package/dist/collab/handoff.d.ts +350 -0
  62. package/dist/collab/handoff.js +513 -0
  63. package/dist/collab/index.d.ts +9 -0
  64. package/dist/collab/index.js +8 -0
  65. package/dist/collab/lifecycle.d.ts +449 -0
  66. package/dist/collab/lifecycle.js +534 -0
  67. package/dist/collab/messages.d.ts +148 -0
  68. package/dist/collab/messages.js +583 -0
  69. package/dist/collab/notify.d.ts +47 -0
  70. package/dist/collab/notify.js +140 -0
  71. package/dist/collab/preflight.d.ts +31 -0
  72. package/dist/collab/preflight.js +271 -0
  73. package/dist/collab/resources.d.ts +119 -0
  74. package/dist/collab/resources.js +451 -0
  75. package/dist/collab/store.d.ts +143 -0
  76. package/dist/collab/store.js +606 -0
  77. package/dist/collab/types.d.ts +354 -0
  78. package/dist/collab/types.js +1 -0
  79. package/dist/collab/validation.d.ts +10 -0
  80. package/dist/collab/validation.js +303 -0
  81. package/dist/continuity/context.d.ts +24 -0
  82. package/dist/continuity/context.js +179 -0
  83. package/dist/continuity/index.d.ts +3 -0
  84. package/dist/continuity/index.js +1 -0
  85. package/dist/continuity/paths.d.ts +37 -0
  86. package/dist/continuity/paths.js +258 -0
  87. package/dist/continuity/service.d.ts +59 -0
  88. package/dist/continuity/service.js +1258 -0
  89. package/dist/hosts/claude/native.d.ts +79 -0
  90. package/dist/hosts/claude/native.js +853 -0
  91. package/dist/hosts/codex/native.d.ts +345 -0
  92. package/dist/hosts/codex/native.js +1528 -0
  93. package/dist/hosts/codex/protocol.d.ts +51 -0
  94. package/dist/hosts/codex/protocol.js +42 -0
  95. package/dist/hosts/detect.d.ts +28 -0
  96. package/dist/hosts/detect.js +73 -0
  97. package/dist/hosts/index.d.ts +10 -0
  98. package/dist/hosts/index.js +10 -0
  99. package/dist/hosts/integration/bundle.d.ts +16 -0
  100. package/dist/hosts/integration/bundle.js +81 -0
  101. package/dist/hosts/integration/hook-entry.d.ts +1 -0
  102. package/dist/hosts/integration/hook-entry.js +68 -0
  103. package/dist/hosts/integration/hook.d.ts +32 -0
  104. package/dist/hosts/integration/hook.js +781 -0
  105. package/dist/hosts/integration/pi-extension-runtime.d.ts +18 -0
  106. package/dist/hosts/integration/pi-extension-runtime.js +89 -0
  107. package/dist/hosts/integration/pi-template.d.ts +3 -0
  108. package/dist/hosts/integration/pi-template.js +5 -0
  109. package/dist/hosts/integration/service.d.ts +41 -0
  110. package/dist/hosts/integration/service.js +1381 -0
  111. package/dist/hosts/integration/support.d.ts +36 -0
  112. package/dist/hosts/integration/support.js +41 -0
  113. package/dist/hosts/integration/templates.d.ts +22 -0
  114. package/dist/hosts/integration/templates.js +70 -0
  115. package/dist/hosts/pi/protocol.d.ts +22 -0
  116. package/dist/hosts/pi/protocol.js +28 -0
  117. package/dist/hosts/pi/query-result.d.ts +22 -0
  118. package/dist/hosts/pi/query-result.js +710 -0
  119. package/dist/hosts/pi/rpc.d.ts +937 -0
  120. package/dist/hosts/pi/rpc.js +1621 -0
  121. package/dist/hosts/pilot.d.ts +485 -0
  122. package/dist/hosts/pilot.js +593 -0
  123. package/dist/index.d.ts +5 -0
  124. package/dist/index.js +4 -0
  125. package/dist/infra/fs.d.ts +27 -0
  126. package/dist/infra/fs.js +130 -0
  127. package/dist/infra/git.d.ts +60 -0
  128. package/dist/infra/git.js +358 -0
  129. package/dist/infra/owned-process-group.d.ts +173 -0
  130. package/dist/infra/owned-process-group.js +571 -0
  131. package/dist/install/apply.d.ts +65 -0
  132. package/dist/install/apply.js +663 -0
  133. package/dist/install/index.d.ts +5 -0
  134. package/dist/install/index.js +5 -0
  135. package/dist/install/ownership.d.ts +85 -0
  136. package/dist/install/ownership.js +430 -0
  137. package/dist/install/plan.d.ts +39 -0
  138. package/dist/install/plan.js +97 -0
  139. package/dist/install/rollback.d.ts +18 -0
  140. package/dist/install/rollback.js +544 -0
  141. package/dist/install/runtime.d.ts +76 -0
  142. package/dist/install/runtime.js +1145 -0
  143. package/dist/knowledge/catalog.d.ts +47 -0
  144. package/dist/knowledge/catalog.js +308 -0
  145. package/dist/knowledge/context.d.ts +28 -0
  146. package/dist/knowledge/context.js +61 -0
  147. package/dist/knowledge/index.d.ts +3 -0
  148. package/dist/knowledge/index.js +1 -0
  149. package/dist/knowledge/journal.d.ts +16 -0
  150. package/dist/knowledge/journal.js +109 -0
  151. package/dist/knowledge/lifecycle.d.ts +8 -0
  152. package/dist/knowledge/lifecycle.js +1001 -0
  153. package/dist/knowledge/paths.d.ts +37 -0
  154. package/dist/knowledge/paths.js +236 -0
  155. package/dist/knowledge/refs.d.ts +36 -0
  156. package/dist/knowledge/refs.js +267 -0
  157. package/dist/knowledge/service.d.ts +21 -0
  158. package/dist/knowledge/service.js +393 -0
  159. package/dist/knowledge/types.d.ts +171 -0
  160. package/dist/knowledge/types.js +1 -0
  161. package/dist/knowledge/write.d.ts +8 -0
  162. package/dist/knowledge/write.js +1223 -0
  163. package/dist/package-info.d.ts +6 -0
  164. package/dist/package-info.js +19 -0
  165. package/dist/schemas/code.d.ts +377 -0
  166. package/dist/schemas/code.js +145 -0
  167. package/dist/schemas/collaboration.d.ts +115 -0
  168. package/dist/schemas/collaboration.js +106 -0
  169. package/dist/schemas/continuity.d.ts +315 -0
  170. package/dist/schemas/continuity.js +199 -0
  171. package/dist/schemas/diagnostics.d.ts +28 -0
  172. package/dist/schemas/diagnostics.js +27 -0
  173. package/dist/schemas/doctor.d.ts +157 -0
  174. package/dist/schemas/doctor.js +75 -0
  175. package/dist/schemas/document.d.ts +644 -0
  176. package/dist/schemas/document.js +296 -0
  177. package/dist/schemas/errors.d.ts +177 -0
  178. package/dist/schemas/errors.js +176 -0
  179. package/dist/schemas/evidence.d.ts +24 -0
  180. package/dist/schemas/evidence.js +16 -0
  181. package/dist/schemas/host.d.ts +436 -0
  182. package/dist/schemas/host.js +201 -0
  183. package/dist/schemas/index.d.ts +15 -0
  184. package/dist/schemas/index.js +15 -0
  185. package/dist/schemas/init.d.ts +121 -0
  186. package/dist/schemas/init.js +94 -0
  187. package/dist/schemas/install.d.ts +213 -0
  188. package/dist/schemas/install.js +139 -0
  189. package/dist/schemas/machine.d.ts +43 -0
  190. package/dist/schemas/machine.js +96 -0
  191. package/dist/schemas/outcome.d.ts +23 -0
  192. package/dist/schemas/outcome.js +17 -0
  193. package/dist/schemas/paths.d.ts +19 -0
  194. package/dist/schemas/paths.js +63 -0
  195. package/dist/schemas/workspace.d.ts +120 -0
  196. package/dist/schemas/workspace.js +201 -0
  197. package/dist/workspace/config-file.d.ts +36 -0
  198. package/dist/workspace/config-file.js +237 -0
  199. package/dist/workspace/config.d.ts +28 -0
  200. package/dist/workspace/config.js +79 -0
  201. package/dist/workspace/discover.d.ts +34 -0
  202. package/dist/workspace/discover.js +195 -0
  203. package/dist/workspace/documents.d.ts +21 -0
  204. package/dist/workspace/documents.js +395 -0
  205. package/dist/workspace/ignore.d.ts +10 -0
  206. package/dist/workspace/ignore.js +48 -0
  207. package/dist/workspace/index.d.ts +7 -0
  208. package/dist/workspace/index.js +3 -0
  209. package/dist/workspace/repositories.d.ts +24 -0
  210. package/dist/workspace/repositories.js +411 -0
  211. package/dist/workspace/resolve.d.ts +24 -0
  212. package/dist/workspace/resolve.js +268 -0
  213. package/dist/workspace/types.d.ts +71 -0
  214. package/dist/workspace/types.js +1 -0
  215. package/dist/workspace/validation.d.ts +18 -0
  216. package/dist/workspace/validation.js +197 -0
  217. package/package.json +105 -4
  218. package/skills/pappra/SKILL.md +68 -0
  219. package/skills/pappra/references/hosts.md +30 -0
  220. package/skills/pappra/references/lifecycle.md +37 -0
  221. package/skills/pappra/references/operations.md +14 -0
@@ -0,0 +1,571 @@
1
+ import { errorCode } from '../collab/validation.js';
2
+ import { execFileSync, spawn } from 'node:child_process';
3
+ // Scoped Unix process-group helper. It exists to extend a stop beyond the single owned parent
4
+ // into the exact process group that parent created, and nothing else.
5
+ //
6
+ // Ownership is unforgeable: every authoritative field (pgid, leader pid, incarnation, child)
7
+ // lives in a private WeakMap keyed by an immutable, frozen opaque token. A caller-supplied
8
+ // object, a spread copy, or an attempted field write can therefore never redirect a stop to
9
+ // another group.
10
+ //
11
+ // Boundaries this module states rather than hides:
12
+ // * Only tokens returned by `spawnOwnedGroup` are accepted; there is deliberately no API
13
+ // that takes an arbitrary pid or pgid.
14
+ // * Signals go to `-pgid` of that one group. Other sessions, other groups and arbitrary
15
+ // process trees are never signalled.
16
+ // * A descendant that calls `setsid` (or otherwise escapes the group) is explicitly outside
17
+ // the guarantee; the helper can only report what the group still contains.
18
+ // * "Stopped" means no live (non-zombie) member remained in the group. It is not proof of
19
+ // task-level side effects; that proof stays task-specific.
20
+ // * No supervisor or daemon is kept: state lives in the private map for the handle's life.
21
+ // * A child `error` event (for example a nonexistent executable) is always handled, so a
22
+ // failed spawn refuses without leaking an unhandled ChildProcess error.
23
+ const STATE = new WeakMap();
24
+ const DEFAULT_TERM_MS = 2000;
25
+ const DEFAULT_KILL_MS = 2000;
26
+ const DEFAULT_POLL_MS = 25;
27
+ const MAX_WAIT_MS = 30000;
28
+ export class OwnedGroupRefusal extends Error {
29
+ code;
30
+ detail;
31
+ constructor(code, detail = null) {
32
+ super(code);
33
+ this.code = code;
34
+ this.detail = detail;
35
+ this.code = code;
36
+ this.detail = detail;
37
+ }
38
+ }
39
+ function refuse(code, detail = null) {
40
+ throw new OwnedGroupRefusal(code, detail);
41
+ }
42
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
43
+ /** `ps` is the portable-enough observation on macOS and Linux; null means "not observable". */
44
+ function psTable() {
45
+ let out;
46
+ try {
47
+ out = execFileSync('ps', ['-axo', 'pid=,ppid=,pgid=,state=,lstart='], {
48
+ encoding: 'utf8',
49
+ maxBuffer: 8 * 1024 * 1024,
50
+ timeout: 5000,
51
+ });
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ const rows = [];
57
+ for (const line of out.split('\n')) {
58
+ const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line);
59
+ if (!match)
60
+ continue;
61
+ rows.push({
62
+ pid: Number(match[1]),
63
+ ppid: Number(match[2]),
64
+ pgid: Number(match[3]),
65
+ state: match[4],
66
+ start_identity: `${match[1]}@${match[5]}`,
67
+ });
68
+ }
69
+ return rows;
70
+ }
71
+ function psRows() {
72
+ const rows = psTable();
73
+ if (rows === null)
74
+ return null;
75
+ return rows.map(({ pid, pgid, state, start_identity }) => ({
76
+ pid,
77
+ pgid,
78
+ state,
79
+ start_identity,
80
+ }));
81
+ }
82
+ /** Members of this exact owned group, or null when the host cannot observe process tables. */
83
+ export function observeGroup(handle) {
84
+ const state = assertOwned(handle);
85
+ const rows = psRows();
86
+ if (rows === null)
87
+ return null;
88
+ return rows.filter((row) => row.pgid === state.pgid);
89
+ }
90
+ function live(rows) {
91
+ return rows.filter((row) => row.state !== 'Z');
92
+ }
93
+ function assertOwned(handle) {
94
+ const state = handle !== null && typeof handle === 'object'
95
+ ? STATE.get(handle)
96
+ : undefined;
97
+ if (!state)
98
+ refuse('unforgeable_handle_required');
99
+ return state;
100
+ }
101
+ /**
102
+ * Spawn one child in its own detached process group and keep the exact incarnation of the
103
+ * group leader in private state. Only the returned frozen token can later stop that group.
104
+ */
105
+ export function spawnOwnedGroup(command, args = [], options = {}) {
106
+ if (typeof command !== 'string' || !command || command.includes('\0'))
107
+ refuse('command_required');
108
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string'))
109
+ refuse('arguments_invalid');
110
+ let child;
111
+ try {
112
+ child = spawn(command, args, {
113
+ cwd: options.cwd ?? undefined,
114
+ env: options.env ?? undefined,
115
+ detached: true,
116
+ stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'],
117
+ });
118
+ }
119
+ catch (error) {
120
+ refuse('spawn_failed', error instanceof Error ? error.message : String(error));
121
+ }
122
+ // Attach before any refusal: a failed spawn emits 'error' asynchronously and must never
123
+ // surface as an unhandled event that would crash the caller.
124
+ let spawnError = null;
125
+ child.on('error', (error) => {
126
+ spawnError ??= error;
127
+ });
128
+ for (const stream of [child.stdout, child.stderr, child.stdin])
129
+ stream?.on('error', () => {
130
+ /* drained */
131
+ });
132
+ if (!child.pid)
133
+ refuse('spawn_failed', spawnError === null ? null : String(spawnError));
134
+ const created = psRows();
135
+ const leader = created === null
136
+ ? null
137
+ : (created.find((row) => row.pid === child.pid) ?? null);
138
+ const handle = Object.freeze({});
139
+ STATE.set(handle, {
140
+ pgid: child.pid,
141
+ leader_pid: child.pid,
142
+ incarnation: leader?.start_identity ?? null,
143
+ child,
144
+ });
145
+ return handle;
146
+ }
147
+ /**
148
+ * Genuine-handle-only accessor: returns the ChildProcess created by this exact handle. The
149
+ * handle remains a frozen opaque token, so a caller can never pass an arbitrary pid/pgid as
150
+ * authority for signalling.
151
+ */
152
+ export function ownedChild(handle) {
153
+ return assertOwned(handle).child;
154
+ }
155
+ /** Observes only this handle's own group: parent exit is not group exit. */
156
+ export function groupState(handle) {
157
+ const state = assertOwned(handle);
158
+ const members = observeGroup(handle);
159
+ const rows = members ?? [];
160
+ return {
161
+ observable: members !== null,
162
+ pgid: state.pgid,
163
+ parent_exited: state.child.exitCode !== null || state.child.signalCode !== null,
164
+ members: rows.map(({ pid, pgid, state: rowState, start_identity }) => ({
165
+ pid,
166
+ pgid,
167
+ state: rowState,
168
+ start_identity,
169
+ })),
170
+ live_members: live(rows).map((row) => row.pid),
171
+ group_live: live(rows).length > 0,
172
+ };
173
+ }
174
+ async function waitForNoLiveMembers(state, budgetMs, pollMs) {
175
+ const until = Date.now() + budgetMs;
176
+ for (;;) {
177
+ const rows = psRows();
178
+ if (rows === null)
179
+ refuse('process_observation_unsupported');
180
+ const remaining = live(rows.filter((row) => row.pgid === state.pgid));
181
+ if (!remaining.length)
182
+ return remaining;
183
+ if (Date.now() >= until)
184
+ return remaining;
185
+ await delay(Math.min(pollMs, Math.max(1, until - Date.now())));
186
+ }
187
+ }
188
+ function signalGroup(pgid, signal) {
189
+ try {
190
+ process.kill(-pgid, signal);
191
+ }
192
+ catch (error) {
193
+ if (errorCode(error) !== 'ESRCH')
194
+ throw error;
195
+ }
196
+ }
197
+ function signalOwnGroup(state, signal) {
198
+ signalGroup(state.pgid, signal);
199
+ }
200
+ /**
201
+ * Bounded TERM then KILL against this handle's own group. Refuses when the group leader was
202
+ * replaced by a different incarnation, and reports `stopped: false` while any member lives.
203
+ */
204
+ export async function stopOwnedGroup(handle, options = {}) {
205
+ const state = assertOwned(handle);
206
+ const termMs = bounded(options.termMs ?? DEFAULT_TERM_MS, 'term_ms_out_of_range');
207
+ const killMs = bounded(options.killMs ?? DEFAULT_KILL_MS, 'kill_ms_out_of_range');
208
+ const pollMs = Math.min(bounded(options.pollMs ?? DEFAULT_POLL_MS, 'poll_ms_out_of_range'), termMs || 1);
209
+ const observed = observeGroup(handle);
210
+ if (observed === null)
211
+ refuse('process_observation_unsupported');
212
+ if (!live(observed).length) {
213
+ return {
214
+ stopped: true,
215
+ already_stopped: true,
216
+ term_sent: false,
217
+ kill_sent: false,
218
+ remaining: [],
219
+ };
220
+ }
221
+ const leader = observed.find((row) => row.pid === state.leader_pid && row.state !== 'Z') ??
222
+ null;
223
+ if (leader &&
224
+ state.incarnation &&
225
+ leader.start_identity !== state.incarnation)
226
+ refuse('incarnation_mismatch', leader.start_identity);
227
+ if (!leader && !state.incarnation)
228
+ refuse('incarnation_unavailable', 'the group leader is gone and no incarnation was ever recorded');
229
+ signalOwnGroup(state, 'SIGTERM');
230
+ let remaining = await waitForNoLiveMembers(state, termMs, pollMs);
231
+ let killSent = false;
232
+ if (remaining.length) {
233
+ signalOwnGroup(state, 'SIGKILL');
234
+ killSent = true;
235
+ remaining = await waitForNoLiveMembers(state, killMs, pollMs);
236
+ }
237
+ return {
238
+ stopped: remaining.length === 0,
239
+ already_stopped: false,
240
+ term_sent: true,
241
+ kill_sent: killSent,
242
+ remaining: remaining.map((row) => ({
243
+ pid: row.pid,
244
+ state: row.state,
245
+ start_identity: row.start_identity,
246
+ })),
247
+ };
248
+ }
249
+ // ---------------------------------------------------------------------------
250
+ // Read-only owned-descendant-tree snapshot (opt-in controlled runtime)
251
+ // ---------------------------------------------------------------------------
252
+ const SNAPSHOTS = new WeakMap();
253
+ function liveMembersOf(pgid, rows) {
254
+ return rows.filter((row) => row.pgid === pgid && row.state !== 'Z');
255
+ }
256
+ /**
257
+ * Read-only snapshot of the genuine owned root and the processes that are actually its
258
+ * descendants (tree traversal from the private root pid), keeping exact PID + start identities
259
+ * and the members of every group those processes belong to. The snapshot is a frozen opaque
260
+ * token backed by private state; no caller-supplied PID/PGID ever becomes authority, and the
261
+ * caller's groups are captured before any native interrupt so orphaned groups remain targeted.
262
+ *
263
+ * A group is only captured when its leader is the owned root or a captured descendant AND every
264
+ * current member of that group is itself a proven descendant of the owned root. A group leader
265
+ * alone can never grant authority over an unrelated process that merely joined the group, so a
266
+ * contaminated tree is refused wholesale instead of being stopped.
267
+ */
268
+ export function snapshotOwnedTree(handle) {
269
+ const state = assertOwned(handle);
270
+ const rows = psTable();
271
+ if (rows === null)
272
+ refuse('process_observation_unsupported');
273
+ const root = rows.find((row) => row.pid === state.leader_pid && row.state !== 'Z') ??
274
+ null;
275
+ if (!root)
276
+ refuse('owned_root_not_observed', 'the owned group root is no longer observable');
277
+ if (state.incarnation !== null && root.start_identity !== state.incarnation)
278
+ refuse('incarnation_mismatch', root.start_identity);
279
+ const byParent = new Map();
280
+ for (const row of rows) {
281
+ if (!byParent.has(row.ppid))
282
+ byParent.set(row.ppid, []);
283
+ byParent.get(row.ppid).push(row);
284
+ }
285
+ const descendants = [];
286
+ const seen = new Set([root.pid]);
287
+ const queue = [root.pid];
288
+ while (queue.length) {
289
+ for (const child of byParent.get(queue.shift()) ?? []) {
290
+ if (seen.has(child.pid))
291
+ continue;
292
+ seen.add(child.pid);
293
+ descendants.push(child);
294
+ queue.push(child.pid);
295
+ }
296
+ }
297
+ const captured = [root, ...descendants];
298
+ const capturedPids = new Set(captured.map((row) => row.pid));
299
+ const groupIds = [...new Set(captured.map((row) => row.pgid))];
300
+ for (const pgid of groupIds) {
301
+ // A captured group may only be targeted when its leader is the owned root or a captured
302
+ // descendant; this prevents a snapshot from ever authorizing a shared/unrelated group.
303
+ if (pgid !== state.pgid && !capturedPids.has(pgid))
304
+ refuse('group_leader_unverified', String(pgid));
305
+ for (const member of rows.filter((row) => row.pgid === pgid)) {
306
+ // Every current member of a captured group must itself be a proven descendant of the owned
307
+ // root. A leader alone does not confer authority over a foreign joiner, so its presence
308
+ // refuses the whole snapshot rather than widening the stop to an unrelated process.
309
+ if (!capturedPids.has(member.pid))
310
+ refuse('foreign_group_member', `pid ${member.pid} in pgid ${pgid} is not a proven descendant of the owned root`);
311
+ }
312
+ }
313
+ const groups = groupIds.map((pgid) => ({
314
+ pgid,
315
+ members: rows
316
+ .filter((row) => row.pgid === pgid)
317
+ .map((row) => ({
318
+ pid: row.pid,
319
+ ppid: row.ppid,
320
+ pgid: row.pgid,
321
+ state: row.state,
322
+ start_identity: row.start_identity,
323
+ })),
324
+ }));
325
+ const snapshot = Object.freeze({
326
+ scope: 'owned-descendant-tree-snapshot',
327
+ captured_at: new Date().toISOString(),
328
+ original_pgid: state.pgid,
329
+ root: {
330
+ pid: root.pid,
331
+ pgid: root.pgid,
332
+ start_identity: root.start_identity,
333
+ },
334
+ descendants: descendants.map((row) => ({
335
+ pid: row.pid,
336
+ ppid: row.ppid,
337
+ pgid: row.pgid,
338
+ start_identity: row.start_identity,
339
+ })),
340
+ groups: groups.map((group) => ({
341
+ pgid: group.pgid,
342
+ member_count: group.members.length,
343
+ })),
344
+ });
345
+ // Ownership is private: only the genuine group handle that captured this snapshot may stop it.
346
+ SNAPSHOTS.set(snapshot, {
347
+ handle,
348
+ original_pgid: state.pgid,
349
+ root_pid: root.pid,
350
+ root_identity: root.start_identity,
351
+ groups,
352
+ members: captured.map((row) => ({
353
+ pid: row.pid,
354
+ ppid: row.ppid,
355
+ pgid: row.pgid,
356
+ start_identity: row.start_identity,
357
+ })),
358
+ });
359
+ return snapshot;
360
+ }
361
+ /**
362
+ * Re-derives a fail-closed verdict from one fresh process table. Returns `{ ok: true as const, targets }`
363
+ * only when every live member of every captured group is a captured member with its exact
364
+ * incarnation, and every still-live captured PID is still in its captured group. Anything else —
365
+ * an uncaptured joiner, a reused id, a captured member that moved PGID — is `{ ok: false as const }` with a
366
+ * refusal code, so a caller can refuse before signalling or report an unproven stop.
367
+ */
368
+ function proofFromRows(meta, rows) {
369
+ const byPid = new Map(rows.map((row) => [row.pid, row]));
370
+ for (const group of meta.groups) {
371
+ for (const member of liveMembersOf(group.pgid, rows)) {
372
+ const expected = group.members.find((entry) => entry.pid === member.pid);
373
+ if (!expected)
374
+ return {
375
+ ok: false,
376
+ code: 'group_membership_unproven',
377
+ detail: `pid ${member.pid} in pgid ${group.pgid} was not captured`,
378
+ };
379
+ if (expected.start_identity !== member.start_identity)
380
+ return {
381
+ ok: false,
382
+ code: 'group_identity_unproven',
383
+ detail: `pid ${member.pid} incarnation changed`,
384
+ };
385
+ if (member.pgid !== expected.pgid)
386
+ return {
387
+ ok: false,
388
+ code: 'group_membership_unproven',
389
+ detail: `pid ${member.pid} is not in its captured group`,
390
+ };
391
+ }
392
+ }
393
+ for (const member of meta.members) {
394
+ const now = byPid.get(member.pid);
395
+ // Gone or a zombie: this captured member is no longer live.
396
+ if (!now || now.state === 'Z')
397
+ continue;
398
+ // A reused PID belongs to a different incarnation, so the captured member is already gone;
399
+ // an unchanged identity that sits in another group is a moved member and stays unproven.
400
+ if (now.start_identity !== member.start_identity)
401
+ continue;
402
+ if (now.pgid !== member.pgid)
403
+ return {
404
+ ok: false,
405
+ code: 'group_member_moved',
406
+ detail: `pid ${member.pid} moved from pgid ${member.pgid} to pgid ${now.pgid}`,
407
+ };
408
+ }
409
+ const targets = [
410
+ ...new Set(meta.groups
411
+ .filter((group) => liveMembersOf(group.pgid, rows).length)
412
+ .map((group) => group.pgid)),
413
+ ];
414
+ return { ok: true, targets };
415
+ }
416
+ function liveCapturedGroupRows(meta, rows) {
417
+ return meta.groups
418
+ .flatMap((group) => liveMembersOf(group.pgid, rows))
419
+ .map((row) => ({
420
+ pid: row.pid,
421
+ pgid: row.pgid,
422
+ state: row.state,
423
+ start_identity: row.start_identity,
424
+ }));
425
+ }
426
+ async function waitForGroupsEmpty(targets, budgetMs, pollMs) {
427
+ const until = Date.now() + budgetMs;
428
+ for (;;) {
429
+ const rows = psTable();
430
+ if (rows === null)
431
+ refuse('process_observation_unsupported');
432
+ const remaining = targets.flatMap((pgid) => liveMembersOf(pgid, rows));
433
+ if (!remaining.length)
434
+ return { empty: true, remaining: [] };
435
+ if (Date.now() >= until)
436
+ return { empty: false, remaining };
437
+ await delay(Math.min(pollMs, Math.max(1, until - Date.now())));
438
+ }
439
+ }
440
+ /**
441
+ * Bounded global TERM then KILL across the snapshotted groups (one shared budget, never
442
+ * multiplied by group count). Only the runtime whose owned group captured the snapshot may stop
443
+ * it. Signalling happens after every current live member of every captured group matches a
444
+ * snapshotted PID + start identity and the same captured PGID, and every still-live captured PID
445
+ * is still in its captured group; an uncaptured joiner, a reused id, a moved captured member or an
446
+ * observation failure refuses unproven and signals nothing. Immediately before escalation the
447
+ * same verdict is recomputed, so a group whose membership or incarnation changed during TERM is
448
+ * never SIGKILLed; a `stopped: true` result requires that same positive verdict on the final
449
+ * observation.
450
+ */
451
+ export async function stopSnapshottedTree(snapshot, options = {}) {
452
+ const meta = snapshot !== null && typeof snapshot === 'object'
453
+ ? SNAPSHOTS.get(snapshot)
454
+ : undefined;
455
+ if (!meta)
456
+ refuse('unforgeable_snapshot_required');
457
+ if (options.handle === undefined || options.handle !== meta.handle)
458
+ refuse('snapshot_owner_mismatch', 'the snapshot was captured by a different owned group');
459
+ const termMs = bounded(options.termMs ?? DEFAULT_TERM_MS, 'term_ms_out_of_range');
460
+ const killMs = bounded(options.killMs ?? DEFAULT_KILL_MS, 'kill_ms_out_of_range');
461
+ const pollMs = Math.min(bounded(options.pollMs ?? DEFAULT_POLL_MS, 'poll_ms_out_of_range'), termMs || 1);
462
+ const rows = psTable();
463
+ if (rows === null)
464
+ refuse('process_observation_unsupported');
465
+ const initial = proofFromRows(meta, rows);
466
+ if (!initial.ok)
467
+ refuse(initial.code, initial.detail);
468
+ const base = {
469
+ scope: 'owned-descendant-tree-snapshot',
470
+ original_pgid: meta.original_pgid,
471
+ root_pid: meta.root_pid,
472
+ groups: meta.groups.map((group) => group.pgid),
473
+ targeted_groups: initial.targets,
474
+ };
475
+ if (!initial.targets.length) {
476
+ return {
477
+ ...base,
478
+ stopped: true,
479
+ already_stopped: true,
480
+ term_sent: false,
481
+ kill_sent: false,
482
+ remaining: [],
483
+ group_observed: true,
484
+ };
485
+ }
486
+ for (const pgid of initial.targets)
487
+ signalGroup(pgid, 'SIGTERM');
488
+ let state = await waitForGroupsEmpty(initial.targets, termMs, pollMs);
489
+ let killSent = false;
490
+ if (!state.empty) {
491
+ // Re-observe and revalidate immediately before escalation. A group whose membership or whose
492
+ // member incarnation changed during TERM is never escalated: the call stays fail-closed and
493
+ // signals nothing further.
494
+ const recheckRows = psTable();
495
+ if (recheckRows === null) {
496
+ return {
497
+ ...base,
498
+ stopped: false,
499
+ already_stopped: false,
500
+ term_sent: true,
501
+ kill_sent: false,
502
+ group_observed: false,
503
+ escalation_refused: 'process_observation_unsupported',
504
+ remaining: [],
505
+ };
506
+ }
507
+ const escalation = proofFromRows(meta, recheckRows);
508
+ if (!escalation.ok) {
509
+ return {
510
+ ...base,
511
+ stopped: false,
512
+ already_stopped: false,
513
+ term_sent: true,
514
+ kill_sent: false,
515
+ group_observed: true,
516
+ escalation_refused: escalation.code,
517
+ escalation_detail: escalation.detail,
518
+ remaining: liveCapturedGroupRows(meta, recheckRows),
519
+ };
520
+ }
521
+ if (escalation.targets.length) {
522
+ for (const pgid of escalation.targets)
523
+ signalGroup(pgid, 'SIGKILL');
524
+ killSent = true;
525
+ state = await waitForGroupsEmpty(escalation.targets, killMs, pollMs);
526
+ }
527
+ }
528
+ const finalRows = psTable();
529
+ if (finalRows === null) {
530
+ return {
531
+ ...base,
532
+ stopped: false,
533
+ already_stopped: false,
534
+ term_sent: true,
535
+ kill_sent: killSent,
536
+ remaining: [],
537
+ group_observed: false,
538
+ };
539
+ }
540
+ const proof = proofFromRows(meta, finalRows);
541
+ if (!proof.ok) {
542
+ // A moved or otherwise unproven captured member makes the positive proof unavailable: this is
543
+ // explicitly not `stopped`, even when the captured groups themselves look empty.
544
+ return {
545
+ ...base,
546
+ stopped: false,
547
+ already_stopped: false,
548
+ term_sent: true,
549
+ kill_sent: killSent,
550
+ group_observed: true,
551
+ final_proof_refused: proof.code,
552
+ final_proof_detail: proof.detail,
553
+ remaining: liveCapturedGroupRows(meta, finalRows),
554
+ };
555
+ }
556
+ const remaining = liveCapturedGroupRows(meta, finalRows);
557
+ return {
558
+ ...base,
559
+ stopped: remaining.length === 0,
560
+ already_stopped: false,
561
+ term_sent: true,
562
+ kill_sent: killSent,
563
+ remaining,
564
+ group_observed: true,
565
+ };
566
+ }
567
+ function bounded(value, code) {
568
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_WAIT_MS)
569
+ refuse(code);
570
+ return value;
571
+ }
@@ -0,0 +1,65 @@
1
+ import type { Diagnostic } from '../schemas/diagnostics.js';
2
+ import { type InstallJournal, type ManagedFileEntry, type RuntimeManifest, type WorkspaceInstallManifest } from '../schemas/install.js';
3
+ import type { InitPlan } from '../schemas/init.js';
4
+ import type { PlannedFileContent } from './plan.js';
5
+ export interface FileIdentity {
6
+ readonly dev: number;
7
+ readonly ino: number;
8
+ }
9
+ export interface ManagedApplyContext {
10
+ readonly root: string;
11
+ readonly installId: string;
12
+ readonly planId: string;
13
+ readonly lockPath: string;
14
+ readonly lockToken: string;
15
+ readonly journalFile: string;
16
+ readonly manifest: WorkspaceInstallManifest;
17
+ journal: InstallJournal;
18
+ readonly entries: ManagedFileEntry[];
19
+ readonly plannedPaths: readonly string[];
20
+ }
21
+ export type ManagedApplyBegin = {
22
+ readonly ok: true;
23
+ readonly context: ManagedApplyContext;
24
+ } | {
25
+ readonly ok: false;
26
+ readonly status: 'locked' | 'invalid' | 'failed';
27
+ readonly diagnostics: readonly Diagnostic[];
28
+ readonly residualPaths: readonly string[];
29
+ };
30
+ export declare function beginManagedApply(root: string, plan: InitPlan, writes: readonly PlannedFileContent[], rootIdentity: FileIdentity): Promise<ManagedApplyBegin>;
31
+ export declare function recordManagedFile(context: ManagedApplyContext, path: string, digest: string, identity: FileIdentity): Promise<{
32
+ readonly ok: true;
33
+ } | {
34
+ readonly ok: false;
35
+ readonly diagnostics: readonly Diagnostic[];
36
+ }>;
37
+ export interface CompleteManagedApply {
38
+ readonly ok: boolean;
39
+ readonly manifest: WorkspaceInstallManifest | null;
40
+ readonly journal: InstallJournal;
41
+ readonly diagnostics: readonly Diagnostic[];
42
+ readonly residualPaths: readonly string[];
43
+ }
44
+ export declare function completeManagedApply(context: ManagedApplyContext): Promise<CompleteManagedApply>;
45
+ export declare function failManagedApply(context: ManagedApplyContext, message: string, residuals: readonly string[]): Promise<CompleteManagedApply>;
46
+ export interface UpdateMachineRuntimeOptions {
47
+ /** Fault-injection seams for tests; the CLI never supplies these hooks. */
48
+ readonly beforeWrite?: () => Promise<void> | void;
49
+ readonly beforeFinalJournalPersist?: () => Promise<void> | void;
50
+ }
51
+ export interface ManagedMachineUpdateResult {
52
+ readonly ok: boolean;
53
+ readonly status: 'updated' | 'unchanged' | 'unmanaged' | 'locked' | 'conflict' | 'partial' | 'failed';
54
+ readonly diagnostics: readonly Diagnostic[];
55
+ readonly residualPaths: readonly string[];
56
+ readonly updateInstallId?: string;
57
+ readonly journalPath?: string;
58
+ }
59
+ /** Updates the managed machine.json runtime location after a verified install. */
60
+ export declare function updateMachineRuntime(root: string, runtime: RuntimeManifest, options?: UpdateMachineRuntimeOptions): Promise<ManagedMachineUpdateResult>;
61
+ export interface ManagedWorkspaceState {
62
+ readonly manifest: WorkspaceInstallManifest | null;
63
+ readonly diagnostics: readonly Diagnostic[];
64
+ }
65
+ export declare function inspectManagedWorkspaceState(root: string): Promise<ManagedWorkspaceState>;