@loomhelix/deptrail 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +421 -0
  3. package/dist/actions/action-error.js +11 -0
  4. package/dist/actions/action-jobs.js +199 -0
  5. package/dist/actions/action-service.js +268 -0
  6. package/dist/actions/apply.js +120 -0
  7. package/dist/actions/availability.js +79 -0
  8. package/dist/actions/change-sets.js +117 -0
  9. package/dist/actions/dry-run.js +287 -0
  10. package/dist/actions/edge-moves.js +108 -0
  11. package/dist/actions/edit-plan.js +179 -0
  12. package/dist/actions/file-scope.js +118 -0
  13. package/dist/actions/fix-options.js +141 -0
  14. package/dist/actions/goal-check.js +80 -0
  15. package/dist/actions/install.js +27 -0
  16. package/dist/actions/job-registry.js +104 -0
  17. package/dist/actions/json-edit.js +189 -0
  18. package/dist/actions/labels.js +35 -0
  19. package/dist/actions/node-actions.js +102 -0
  20. package/dist/actions/overrides-location.js +59 -0
  21. package/dist/actions/parent-chain.js +210 -0
  22. package/dist/actions/range-style.js +38 -0
  23. package/dist/actions/recipes.js +31 -0
  24. package/dist/actions/root-files.js +30 -0
  25. package/dist/actions/sandbox.js +175 -0
  26. package/dist/actions/set-range.js +59 -0
  27. package/dist/actions/shared-packages.js +50 -0
  28. package/dist/actions/text-diff.js +203 -0
  29. package/dist/actions/types.js +1 -0
  30. package/dist/actions/update-options.js +232 -0
  31. package/dist/actions/workspace-edit.js +33 -0
  32. package/dist/adapters/command-runner.js +127 -0
  33. package/dist/adapters/manifest-only.js +20 -0
  34. package/dist/adapters/node-modules.js +148 -0
  35. package/dist/adapters/npm-lock.js +149 -0
  36. package/dist/adapters/package-manifest.js +57 -0
  37. package/dist/adapters/pnpm-lock.js +177 -0
  38. package/dist/adapters/resolve-today.js +93 -0
  39. package/dist/cli/cli.js +227 -0
  40. package/dist/cli/open-browser.js +14 -0
  41. package/dist/cli/package-version.js +6 -0
  42. package/dist/cli/usage-text.js +27 -0
  43. package/dist/config/glob.js +119 -0
  44. package/dist/config/npmrc.js +116 -0
  45. package/dist/config/root-config.js +103 -0
  46. package/dist/config/secrets.js +18 -0
  47. package/dist/discovery/find-roots.js +107 -0
  48. package/dist/enrich/credentials.js +143 -0
  49. package/dist/enrich/enrichment-cache.js +42 -0
  50. package/dist/enrich/enrichment-keys.js +71 -0
  51. package/dist/enrich/enrichment-service.js +399 -0
  52. package/dist/enrich/enrichment-status.js +86 -0
  53. package/dist/enrich/enrichment-store.js +143 -0
  54. package/dist/enrich/http.js +201 -0
  55. package/dist/enrich/on-demand.js +220 -0
  56. package/dist/enrich/osv-client.js +260 -0
  57. package/dist/enrich/registry-client.js +123 -0
  58. package/dist/enrich/types.js +1 -0
  59. package/dist/git/blob-reader.js +180 -0
  60. package/dist/git/git-commands.js +32 -0
  61. package/dist/git/git-process.js +71 -0
  62. package/dist/git/git-repository.js +136 -0
  63. package/dist/git/ref-list.js +78 -0
  64. package/dist/git/tree-index.js +87 -0
  65. package/dist/graph/global-index.js +19 -0
  66. package/dist/graph/root-graph-builder.js +114 -0
  67. package/dist/graph/serialize.js +19 -0
  68. package/dist/model.js +2 -0
  69. package/dist/query/children.js +38 -0
  70. package/dist/query/compare-shape.js +13 -0
  71. package/dist/query/compare.js +245 -0
  72. package/dist/query/decorations.js +121 -0
  73. package/dist/query/dependents.js +135 -0
  74. package/dist/query/duplicates.js +23 -0
  75. package/dist/query/enrichment-lookup.js +78 -0
  76. package/dist/query/fixes.js +60 -0
  77. package/dist/query/graph-diff.js +42 -0
  78. package/dist/query/issues.js +235 -0
  79. package/dist/query/lookup.js +49 -0
  80. package/dist/query/node-fix.js +46 -0
  81. package/dist/query/package-insight.js +112 -0
  82. package/dist/query/search.js +61 -0
  83. package/dist/query/subgraph.js +107 -0
  84. package/dist/query/versions.js +103 -0
  85. package/dist/scan/build-root.js +137 -0
  86. package/dist/scan/cache-shape.js +52 -0
  87. package/dist/scan/scan-cache.js +32 -0
  88. package/dist/scan/scan-session.js +127 -0
  89. package/dist/scan/source-registry.js +55 -0
  90. package/dist/server/auth.js +45 -0
  91. package/dist/server/branches.js +105 -0
  92. package/dist/server/error-log.js +9 -0
  93. package/dist/server/root-segment.js +13 -0
  94. package/dist/server/router.js +474 -0
  95. package/dist/server/server.js +166 -0
  96. package/dist/server/source-key.js +1 -0
  97. package/dist/server/static.js +59 -0
  98. package/dist/sources/file-reader.js +55 -0
  99. package/dist/sources/git-reader.js +45 -0
  100. package/dist/ui/assets/index-C4uyB5v3.css +1 -0
  101. package/dist/ui/assets/index-DYAG2LM-.js +51 -0
  102. package/dist/ui/index.html +15 -0
  103. package/package.json +72 -0
@@ -0,0 +1,268 @@
1
+ import path from 'node:path';
2
+ import { findNode } from "../query/lookup.js";
3
+ import { ActionError } from "./action-error.js";
4
+ import { applyJob, checkPreviewSources, checkWrittenFiles, collectJob, currentPreview, currentRevert, previewJob, revertJob } from "./action-jobs.js";
5
+ import { SCANNING_REASON, createAvailability } from "./availability.js";
6
+ import { createChangeSets } from "./change-sets.js";
7
+ import { createJobRegistry } from "./job-registry.js";
8
+ import { actionsOfNode, kindFilterOf, requestedItem } from "./node-actions.js";
9
+ import { REPOSITORY_ROOT_ID } from "./labels.js";
10
+ import { pathExists, rootDirOf } from "./root-files.js";
11
+ import { removeActionSandboxes } from "./sandbox.js";
12
+ import { sharesWorkspacePackage } from "./shared-packages.js";
13
+ export { ActionError };
14
+ const EMPTY_CHANGE_SET = 'There are no changes to preview';
15
+ const NO_PREVIEW = 'No preview';
16
+ const NOT_CANCELABLE = 'This job cannot be canceled now';
17
+ const NODE_MODULES = 'node_modules';
18
+ const WRITING_KINDS = ['apply', 'revert'];
19
+ const WITHOUT_LOG_LINES = Number.MAX_SAFE_INTEGER;
20
+ function unknownJob(jobId) {
21
+ return new ActionError(404, `Unknown job ${jobId}`);
22
+ }
23
+ function summaryJob(job) {
24
+ return { id: job.id, kind: job.kind, state: job.state, ...(job.phase === undefined ? {} : { phase: job.phase }) };
25
+ }
26
+ function revertSummary(record) {
27
+ const sharedRoots = record.sharedRoots ?? [];
28
+ return { files: [...record.files.hashes.keys()], itemCount: record.items.length, ...(sharedRoots.length > 0 ? { sharedRoots } : {}) };
29
+ }
30
+ function isCancellable(job) {
31
+ if (job.state !== 'running')
32
+ return false;
33
+ return job.kind === 'preview' || (job.kind === 'apply' && job.phase === 'installing');
34
+ }
35
+ export function createActionService(options) {
36
+ const { session } = options;
37
+ const jobs = createJobRegistry(options.secrets);
38
+ const availability = createAvailability(options);
39
+ const changeSets = createChangeSets((rootId) => session.root(rootId));
40
+ const deps = { options, changes: changeSets };
41
+ const jobRoots = new Set();
42
+ const activeRuns = new Set();
43
+ let closed = false;
44
+ function requireRoot(rootId) {
45
+ const root = session.root(rootId);
46
+ if (root === undefined)
47
+ throw new ActionError(404, `Unknown root ${rootId}`);
48
+ return root;
49
+ }
50
+ function requireAvailable(root) {
51
+ const check = availability.check(root);
52
+ if ('reason' in check)
53
+ throw new ActionError(409, check.reason);
54
+ return check.manager;
55
+ }
56
+ async function availableRoot(rootId) {
57
+ const root = requireRoot(rootId);
58
+ await availability.probe(root);
59
+ return { root, manager: requireAvailable(root) };
60
+ }
61
+ function requireIdle(rootId) {
62
+ if (jobs.running(rootId) !== undefined)
63
+ throw new ActionError(409, `A job is already running for ${rootId}`);
64
+ }
65
+ function requireNoSharedWrite(root) {
66
+ for (const other of session.scan.roots) {
67
+ if (!sharesWorkspacePackage(root, other))
68
+ continue;
69
+ const running = jobs.running(other.id);
70
+ if (running === undefined || !WRITING_KINDS.includes(running.kind))
71
+ continue;
72
+ throw new ActionError(409, `An apply or revert is running in ${other.id === REPOSITORY_ROOT_ID ? 'the repository root' : other.id}, which shares workspace packages with this root`);
73
+ }
74
+ }
75
+ function launch(rootId, kind, run) {
76
+ jobRoots.add(rootId);
77
+ return jobs.start(rootId, kind, (context) => {
78
+ const running = run(context);
79
+ activeRuns.add(running);
80
+ const forget = () => {
81
+ activeRuns.delete(running);
82
+ };
83
+ running.then(forget, forget);
84
+ return running;
85
+ });
86
+ }
87
+ function nodeOf(root, nodeId) {
88
+ const node = findNode(root, nodeId);
89
+ if (node === undefined)
90
+ throw new ActionError(404, `Unknown node ${nodeId}`);
91
+ return node;
92
+ }
93
+ async function requestedChange(rootId, request) {
94
+ const { root, manager } = await availableRoot(rootId);
95
+ const node = nodeOf(root, request.nodeId);
96
+ const kinds = kindFilterOf(request.kinds);
97
+ const actions = await actionsOfNode({ repoPath: options.repoPath, enrichment: options.enrichment, root, manager, kinds }, node);
98
+ return requestedItem(root, node, actions, request);
99
+ }
100
+ return {
101
+ track(roots) {
102
+ if (closed)
103
+ return;
104
+ for (const root of roots)
105
+ void availability.retryFailedProbe(root);
106
+ },
107
+ summary(root) {
108
+ const check = availability.check(root);
109
+ const changes = changeSets.of(root.id);
110
+ const job = jobs.latest(root.id);
111
+ return {
112
+ available: !('reason' in check),
113
+ ...('reason' in check ? { reason: check.reason } : {}),
114
+ changeCount: changes.items.length,
115
+ ...(job === undefined ? {} : { job: summaryJob(job) }),
116
+ revertable: changes.revert !== undefined,
117
+ };
118
+ },
119
+ isBusy(rootId) {
120
+ return jobs.running(rootId) !== undefined;
121
+ },
122
+ anyBusy() {
123
+ return [...jobRoots].some((rootId) => jobs.running(rootId) !== undefined);
124
+ },
125
+ async nodeActions(rootId, nodeId, kinds) {
126
+ const root = requireRoot(rootId);
127
+ await availability.probe(root);
128
+ const check = availability.check(root);
129
+ if ('reason' in check)
130
+ return { unavailable: check.reason };
131
+ return actionsOfNode({ repoPath: options.repoPath, enrichment: options.enrichment, root, manager: check.manager, kinds }, nodeOf(root, nodeId));
132
+ },
133
+ async changes(rootId) {
134
+ const root = requireRoot(rootId);
135
+ void availability.probe(root);
136
+ const installByDefault = await pathExists(path.join(rootDirOf(options.repoPath, root), NODE_MODULES));
137
+ const changes = changeSets.of(rootId);
138
+ const preview = changeSets.preview(rootId);
139
+ const job = jobs.latest(rootId);
140
+ return {
141
+ items: [...changes.items],
142
+ ...(preview === undefined ? {} : { preview }),
143
+ ...(job === undefined ? {} : { job }),
144
+ revertable: changes.revert !== undefined,
145
+ ...(changes.revert === undefined ? {} : { revert: revertSummary(changes.revert) }),
146
+ installByDefault,
147
+ };
148
+ },
149
+ async addChange(rootId, request) {
150
+ return changeSets.add(rootId, await requestedChange(rootId, request));
151
+ },
152
+ async replaceChange(rootId, itemId, request) {
153
+ return changeSets.replace(rootId, itemId, await requestedChange(rootId, request));
154
+ },
155
+ removeChange(rootId, itemId) {
156
+ requireRoot(rootId);
157
+ changeSets.remove(rootId, itemId);
158
+ },
159
+ clearChanges(rootId) {
160
+ requireRoot(rootId);
161
+ changeSets.clear(rootId);
162
+ },
163
+ restoreCleared(rootId) {
164
+ requireRoot(rootId);
165
+ return changeSets.restoreCleared(rootId);
166
+ },
167
+ startCollect(rootId, kinds) {
168
+ const root = requireRoot(rootId);
169
+ const manager = requireAvailable(root);
170
+ requireIdle(rootId);
171
+ return launch(rootId, 'collect', collectJob(deps, { root, manager, kinds }));
172
+ },
173
+ startPreview(rootId) {
174
+ const root = requireRoot(rootId);
175
+ const manager = requireAvailable(root);
176
+ requireIdle(rootId);
177
+ const changes = changeSets.of(rootId);
178
+ if (changes.items.length === 0)
179
+ throw new ActionError(409, EMPTY_CHANGE_SET);
180
+ return launch(rootId, 'preview', previewJob(deps, { root, manager, items: [...changes.items], changeRevision: changes.revision }));
181
+ },
182
+ previewFileDiff(rootId, index) {
183
+ requireRoot(rootId);
184
+ const record = changeSets.of(rootId).preview;
185
+ if (record === undefined)
186
+ throw new ActionError(404, NO_PREVIEW);
187
+ const file = record.preview.files[index];
188
+ if (file === undefined)
189
+ throw new ActionError(404, `Unknown file ${index}`);
190
+ return file.path === record.lockfile ? { ...record.lockfileDiff } : { diff: file.diff ?? '', truncated: false };
191
+ },
192
+ async startApply(rootId, previewId, install) {
193
+ const { root, manager } = await availableRoot(rootId);
194
+ requireIdle(rootId);
195
+ requireNoSharedWrite(root);
196
+ await checkPreviewSources(currentPreview(changeSets, rootId, previewId));
197
+ requireIdle(rootId);
198
+ requireNoSharedWrite(root);
199
+ const record = currentPreview(changeSets, rootId, previewId);
200
+ return launch(rootId, 'apply', applyJob(deps, { record, manager, install }));
201
+ },
202
+ async startRevert(rootId) {
203
+ const root = requireRoot(rootId);
204
+ if (root.status === 'scanning')
205
+ throw new ActionError(409, SCANNING_REASON);
206
+ requireIdle(rootId);
207
+ requireNoSharedWrite(root);
208
+ await checkWrittenFiles(currentRevert(changeSets, rootId));
209
+ requireIdle(rootId);
210
+ requireNoSharedWrite(root);
211
+ return launch(rootId, 'revert', revertJob(deps, rootId, currentRevert(changeSets, rootId)));
212
+ },
213
+ job(jobId, since) {
214
+ const job = jobs.get(jobId, since);
215
+ if (job === undefined)
216
+ throw unknownJob(jobId);
217
+ return job;
218
+ },
219
+ cancelJob(jobId) {
220
+ const job = jobs.get(jobId, WITHOUT_LOG_LINES);
221
+ if (job === undefined)
222
+ throw unknownJob(jobId);
223
+ if (!isCancellable(job))
224
+ throw new ActionError(409, NOT_CANCELABLE);
225
+ jobs.cancel(jobId);
226
+ },
227
+ async close() {
228
+ closed = true;
229
+ jobs.cancelAll();
230
+ options.runner.killRunning();
231
+ await Promise.allSettled([...activeRuns]);
232
+ await availability.settled();
233
+ await removeActionSandboxes();
234
+ },
235
+ };
236
+ }
237
+ export function disabledActions(reason) {
238
+ const refuse = () => {
239
+ throw new ActionError(409, reason);
240
+ };
241
+ return {
242
+ track() { },
243
+ summary: () => ({ available: false, reason, changeCount: 0, revertable: false }),
244
+ isBusy: () => false,
245
+ anyBusy: () => false,
246
+ nodeActions: async () => ({ unavailable: reason }),
247
+ changes: async () => ({ items: [], revertable: false, installByDefault: false }),
248
+ addChange: async () => refuse(),
249
+ replaceChange: async () => refuse(),
250
+ removeChange: refuse,
251
+ clearChanges: refuse,
252
+ restoreCleared: refuse,
253
+ startCollect: refuse,
254
+ startPreview: refuse,
255
+ previewFileDiff: () => {
256
+ throw new ActionError(404, NO_PREVIEW);
257
+ },
258
+ startApply: async () => refuse(),
259
+ startRevert: async () => refuse(),
260
+ job: (jobId) => {
261
+ throw unknownJob(jobId);
262
+ },
263
+ cancelJob: (jobId) => {
264
+ throw unknownJob(jobId);
265
+ },
266
+ close: async () => { },
267
+ };
268
+ }
@@ -0,0 +1,120 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { messageOf } from "../scan/build-root.js";
5
+ import { OutsideScopeError, hashSources, leavesRoot, resolveInScope, sha256Hex } from "./file-scope.js";
6
+ export class RevertConflictError extends Error {
7
+ paths;
8
+ constructor(paths) {
9
+ super(`Files changed since deptrail wrote them: ${paths.join(', ')}`);
10
+ this.paths = paths;
11
+ }
12
+ }
13
+ export class PartialWriteError extends Error {
14
+ unrestored;
15
+ constructor(message, unrestored) {
16
+ super(message);
17
+ this.name = 'PartialWriteError';
18
+ this.unrestored = unrestored;
19
+ }
20
+ }
21
+ const NEW_FILE_MODE = 0o644;
22
+ function isMissing(failure) {
23
+ return failure.code === 'ENOENT';
24
+ }
25
+ export async function changedSources(scope, sources) {
26
+ const current = await hashSources(scope, [...sources.keys()]);
27
+ return [...sources].filter(([relative, hash]) => current.get(relative) !== hash).map(([relative]) => relative);
28
+ }
29
+ async function existingMode(target) {
30
+ try {
31
+ return (await stat(target)).mode & 0o7777;
32
+ }
33
+ catch (failure) {
34
+ if (isMissing(failure))
35
+ return NEW_FILE_MODE;
36
+ throw failure;
37
+ }
38
+ }
39
+ async function replaceFile(target, text) {
40
+ const mode = await existingMode(target);
41
+ const temporary = path.join(path.dirname(target), `${path.basename(target)}.deptrail-${randomUUID()}.tmp`);
42
+ try {
43
+ await writeFile(temporary, text, { mode });
44
+ await chmod(temporary, mode);
45
+ await rename(temporary, target);
46
+ }
47
+ catch (failure) {
48
+ await rm(temporary, { force: true });
49
+ throw failure;
50
+ }
51
+ }
52
+ async function restoreFile(target, original) {
53
+ if (original === null)
54
+ await rm(target, { force: true });
55
+ else
56
+ await replaceFile(target, original);
57
+ }
58
+ function partialWriteMessage(failedPath, failure, unrestored) {
59
+ return `could not write ${failedPath}: ${messageOf(failure)}; could not restore ${unrestored.join(', ')}`;
60
+ }
61
+ function requireOutsideOriginals(paths, originals) {
62
+ for (const relative of paths) {
63
+ if (leavesRoot(relative) && (originals.get(relative) ?? null) === null) {
64
+ throw new OutsideScopeError(`${relative} does not exist; deptrail never creates a file outside the root directory`);
65
+ }
66
+ }
67
+ }
68
+ function writeOrder(paths, lockfile) {
69
+ const all = [...paths];
70
+ return [...all.filter((relative) => relative !== lockfile).sort(), ...all.filter((relative) => relative === lockfile)];
71
+ }
72
+ export async function writePreviewFiles(scope, written, lockfile, originals) {
73
+ const order = writeOrder(written.keys(), lockfile);
74
+ requireOutsideOriginals(order, originals);
75
+ const targets = new Map();
76
+ for (const relative of order)
77
+ targets.set(relative, await resolveInScope(scope, relative));
78
+ const done = [];
79
+ let writing;
80
+ try {
81
+ for (const relative of order) {
82
+ writing = relative;
83
+ await replaceFile(targets.get(relative), written.get(relative));
84
+ done.push(relative);
85
+ }
86
+ }
87
+ catch (failure) {
88
+ const failedRestores = new Map();
89
+ for (const relative of [...done].reverse()) {
90
+ const original = originals.get(relative) ?? null;
91
+ await restoreFile(targets.get(relative), original).catch(() => failedRestores.set(relative, original));
92
+ }
93
+ if (failedRestores.size === 0)
94
+ throw failure;
95
+ const unrestoredPaths = done.filter((relative) => failedRestores.has(relative));
96
+ const unrestored = {
97
+ originals: new Map(unrestoredPaths.map((relative) => [relative, failedRestores.get(relative) ?? null])),
98
+ hashes: new Map(unrestoredPaths.map((relative) => [relative, sha256Hex(written.get(relative))])),
99
+ };
100
+ throw new PartialWriteError(partialWriteMessage(writing, failure, unrestoredPaths), unrestored);
101
+ }
102
+ return {
103
+ originals: new Map(originals),
104
+ hashes: new Map(order.map((relative) => [relative, sha256Hex(written.get(relative))])),
105
+ };
106
+ }
107
+ export async function changedSinceWrite(scope, files) {
108
+ const current = await hashSources(scope, [...files.hashes.keys()]);
109
+ return [...files.hashes].filter(([relative, hash]) => current.get(relative) !== hash).map(([relative]) => relative);
110
+ }
111
+ export async function revertWrittenFiles(scope, files) {
112
+ const targets = new Map();
113
+ for (const relative of files.hashes.keys())
114
+ targets.set(relative, await resolveInScope(scope, relative));
115
+ const conflicts = await changedSinceWrite(scope, files);
116
+ if (conflicts.length > 0)
117
+ throw new RevertConflictError(conflicts);
118
+ for (const [relative, target] of targets)
119
+ await restoreFile(target, files.originals.get(relative) ?? null);
120
+ }
@@ -0,0 +1,79 @@
1
+ import path from 'node:path';
2
+ import { outsideImporters } from "./file-scope.js";
3
+ import { probeManager } from "./recipes.js";
4
+ export const SCANNING_REASON = 'Waiting for the scan';
5
+ const FAILED_REASON = 'The root failed to scan';
6
+ const LOCKFILE_REASON = 'Actions require a pnpm or npm lockfile';
7
+ const OFFLINE_REASON = 'Actions are unavailable with --offline';
8
+ function managerNameOf(root) {
9
+ return root.manager === 'pnpm' || root.manager === 'npm' ? root.manager : undefined;
10
+ }
11
+ function outsideImporterReason(problem) {
12
+ if (problem.kind === 'outside-repository')
13
+ return `Actions do not support workspace packages outside the repository (${problem.path})`;
14
+ if (problem.kind === 'npm')
15
+ return `Actions do not support workspace packages outside the root (${problem.path})`;
16
+ return `The lockfile lists ${problem.path}, which pnpm-workspace.yaml does not declare`;
17
+ }
18
+ function blockingReason(root, offline) {
19
+ if (root.status === 'scanning')
20
+ return SCANNING_REASON;
21
+ if (root.status === 'failed')
22
+ return FAILED_REASON;
23
+ if (root.source !== 'lockfile' || managerNameOf(root) === undefined)
24
+ return LOCKFILE_REASON;
25
+ if (offline)
26
+ return OFFLINE_REASON;
27
+ const { problem } = outsideImporters(root);
28
+ return problem === undefined ? undefined : outsideImporterReason(problem);
29
+ }
30
+ function probeKey(root, manager) {
31
+ return `${manager}\0${root.path}`;
32
+ }
33
+ export function createAvailability(options) {
34
+ const probes = new Map();
35
+ function startProbe(root, manager) {
36
+ const probe = { current: { state: 'pending' }, done: Promise.resolve() };
37
+ const cwd = path.join(options.repoPath, root.path);
38
+ probe.done = probeManager(options.runner, manager, cwd, options.env, options.secrets).then((result) => {
39
+ probe.current = result.ok ? { state: 'ok', version: result.version } : { state: 'failed', reason: result.reason };
40
+ });
41
+ return probe;
42
+ }
43
+ function probe(root) {
44
+ const manager = managerNameOf(root);
45
+ if (manager === undefined || blockingReason(root, options.offline) !== undefined)
46
+ return Promise.resolve();
47
+ const key = probeKey(root, manager);
48
+ let existing = probes.get(key);
49
+ if (existing === undefined) {
50
+ existing = startProbe(root, manager);
51
+ probes.set(key, existing);
52
+ }
53
+ return existing.done;
54
+ }
55
+ return {
56
+ probe,
57
+ retryFailedProbe(root) {
58
+ const manager = managerNameOf(root);
59
+ if (manager !== undefined && probes.get(probeKey(root, manager))?.current.state === 'failed')
60
+ probes.delete(probeKey(root, manager));
61
+ return probe(root);
62
+ },
63
+ check(root) {
64
+ const manager = managerNameOf(root);
65
+ const blocking = blockingReason(root, options.offline);
66
+ if (manager === undefined || blocking !== undefined)
67
+ return { reason: blocking ?? LOCKFILE_REASON };
68
+ const current = probes.get(probeKey(root, manager))?.current;
69
+ if (current === undefined || current.state === 'pending')
70
+ return { reason: `Checking ${manager}…` };
71
+ if (current.state === 'failed')
72
+ return { reason: current.reason };
73
+ return { manager: { name: manager, version: current.version } };
74
+ },
75
+ async settled() {
76
+ await Promise.all([...probes.values()].map((probe) => probe.done));
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,117 @@
1
+ import { ActionError } from "./action-error.js";
2
+ import { findConflict, sameGoal } from "./edit-plan.js";
3
+ import { goalText, rootLabel } from "./labels.js";
4
+ function unknownChange(itemId) {
5
+ return new ActionError(404, `Unknown change ${itemId}`);
6
+ }
7
+ function rejectionOf(rootId, items, item) {
8
+ const duplicate = items.find((existing) => sameGoal(existing.goal, item.goal));
9
+ if (duplicate !== undefined)
10
+ return new ActionError(409, `${goalText(duplicate.goal)} is already in the changes of ${rootLabel(rootId)}`);
11
+ const conflict = findConflict(items, item);
12
+ if (conflict !== undefined)
13
+ return new ActionError(409, `Conflicts with "${goalText(conflict.item.goal)}": ${conflict.reason}`, conflict.item.id);
14
+ return undefined;
15
+ }
16
+ export function createChangeSets(currentGraph) {
17
+ const roots = new Map();
18
+ function of(rootId) {
19
+ let changes = roots.get(rootId);
20
+ if (changes === undefined) {
21
+ changes = { items: [], revision: 0, cleared: [] };
22
+ roots.set(rootId, changes);
23
+ }
24
+ return changes;
25
+ }
26
+ function edited(changes, items) {
27
+ changes.items = items;
28
+ changes.revision++;
29
+ }
30
+ function editedByUser(changes, items) {
31
+ edited(changes, items);
32
+ changes.cleared = [];
33
+ }
34
+ function restore(rootId, items) {
35
+ const changes = of(rootId);
36
+ const kept = [...changes.items];
37
+ const skipped = [];
38
+ for (const item of items) {
39
+ const rejection = rejectionOf(rootId, kept, item);
40
+ if (rejection === undefined)
41
+ kept.push(item);
42
+ else
43
+ skipped.push({ name: goalText(item.goal), reason: rejection.message });
44
+ }
45
+ const restored = kept.length - changes.items.length;
46
+ if (restored > 0)
47
+ edited(changes, kept);
48
+ return { restored, skipped };
49
+ }
50
+ function isStale(rootId, record) {
51
+ return of(rootId).revision !== record.changeRevision || currentGraph(rootId) !== record.graph;
52
+ }
53
+ return {
54
+ of,
55
+ add(rootId, item) {
56
+ const changes = of(rootId);
57
+ const rejection = rejectionOf(rootId, changes.items, item);
58
+ if (rejection !== undefined)
59
+ throw rejection;
60
+ editedByUser(changes, [...changes.items, item]);
61
+ return item;
62
+ },
63
+ remove(rootId, itemId) {
64
+ const changes = of(rootId);
65
+ if (!changes.items.some((item) => item.id === itemId))
66
+ throw unknownChange(itemId);
67
+ editedByUser(changes, changes.items.filter((item) => item.id !== itemId));
68
+ },
69
+ replace(rootId, itemId, item) {
70
+ const changes = of(rootId);
71
+ if (!changes.items.some((existing) => existing.id === itemId))
72
+ throw unknownChange(itemId);
73
+ const rejection = rejectionOf(rootId, changes.items.filter((existing) => existing.id !== itemId), item);
74
+ if (rejection !== undefined)
75
+ throw rejection;
76
+ editedByUser(changes, changes.items.map((existing) => (existing.id === itemId ? item : existing)));
77
+ return item;
78
+ },
79
+ clear(rootId) {
80
+ const changes = of(rootId);
81
+ changes.cleared = changes.items;
82
+ edited(changes, []);
83
+ },
84
+ restore,
85
+ restoreCleared(rootId) {
86
+ const changes = of(rootId);
87
+ const cleared = changes.cleared;
88
+ changes.cleared = [];
89
+ return restore(rootId, cleared);
90
+ },
91
+ storePreview(rootId, record) {
92
+ of(rootId).preview = record;
93
+ },
94
+ storeRevert(rootId, revert) {
95
+ of(rootId).revert = revert;
96
+ },
97
+ applied(rootId, record, written) {
98
+ const changes = of(rootId);
99
+ const previewed = new Set(record.itemIds);
100
+ const revert = { ...written, items: changes.items.filter((item) => previewed.has(item.id)), sharedRoots: record.sharedRoots };
101
+ edited(changes, changes.items.filter((item) => !previewed.has(item.id)));
102
+ changes.preview = undefined;
103
+ changes.revert = revert;
104
+ return revert;
105
+ },
106
+ reverted(rootId, record) {
107
+ const changes = of(rootId);
108
+ if (changes.revert === record)
109
+ changes.revert = undefined;
110
+ },
111
+ isStale,
112
+ preview(rootId) {
113
+ const record = roots.get(rootId)?.preview;
114
+ return record === undefined ? undefined : { ...record.preview, stale: isStale(rootId, record) };
115
+ },
116
+ };
117
+ }