@abelxiaoxing/cadence 1.0.0 → 1.0.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.
package/src/scheduler.ts CHANGED
@@ -43,6 +43,7 @@ interface Entry<T> {
43
43
  state: EntryState;
44
44
  attempts: number;
45
45
  cancelRequested: boolean;
46
+ cancelReason: unknown;
46
47
  controller: AbortController | null;
47
48
  resolve(outcome: ScheduledOutcome<T>): void;
48
49
  promise: Promise<ScheduledOutcome<T>>;
@@ -58,6 +59,7 @@ function conflictEdgeMatches(
58
59
  ): boolean {
59
60
  return conflicts.some(
60
61
  (edge) =>
62
+ edge === other.taskId ||
61
63
  edge === other.id ||
62
64
  other.declared.read.includes(edge) ||
63
65
  other.declared.write.includes(edge),
@@ -106,6 +108,7 @@ class Batch<T> {
106
108
  state: "queued",
107
109
  attempts: 0,
108
110
  cancelRequested: false,
111
+ cancelReason: undefined,
109
112
  controller: null,
110
113
  resolve,
111
114
  promise,
@@ -150,6 +153,8 @@ export class Scheduler<T> {
150
153
  }
151
154
  }
152
155
  this.pump();
156
+ const done = Promise.all(batch.entries.map((entry) => entry.promise));
157
+ void done.then(() => this.removeBatch(batch));
153
158
  return {
154
159
  batchId,
155
160
  result: (id: string) => {
@@ -157,22 +162,54 @@ export class Scheduler<T> {
157
162
  if (!entry) throw new Error(`unknown scheduled request: ${id}`);
158
163
  return entry.promise;
159
164
  },
160
- done: Promise.all(batch.entries.map((entry) => entry.promise)),
165
+ done,
161
166
  };
162
167
  }
163
168
 
164
- cancel(batchId: string): void {
169
+ private removeBatch(batch: Batch<T>): void {
170
+ const index = this.batches.indexOf(batch);
171
+ if (index >= 0) this.batches.splice(index, 1);
172
+ }
173
+
174
+ cancel(
175
+ batchId: string,
176
+ reason: unknown = new Error("batch cancelled"),
177
+ ): void {
165
178
  const batch = this.batches.find(
166
179
  (candidate) => candidate.batchId === batchId,
167
180
  );
168
- if (!batch) return;
181
+ if (batch) this.cancelBatch(batch, reason);
182
+ }
183
+
184
+ async cancelAll(
185
+ reason: unknown = new Error("batch cancelled"),
186
+ ): Promise<void> {
187
+ const batches = [...this.batches];
188
+ for (const batch of batches) this.requestCancellation(batch, reason);
189
+ for (const batch of batches) this.settleQueuedCancellation(batch);
190
+ await Promise.all(
191
+ batches.flatMap((batch) => batch.entries.map((entry) => entry.promise)),
192
+ );
193
+ }
194
+
195
+ private cancelBatch(batch: Batch<T>, reason: unknown): void {
196
+ this.requestCancellation(batch, reason);
197
+ this.settleQueuedCancellation(batch);
198
+ }
199
+
200
+ private requestCancellation(batch: Batch<T>, reason: unknown): void {
201
+ for (const entry of batch.entries) {
202
+ if (entry.state !== "queued" && entry.state !== "running") continue;
203
+ if (!entry.cancelRequested) entry.cancelReason = reason;
204
+ entry.cancelRequested = true;
205
+ if (entry.state === "running") entry.controller?.abort(reason);
206
+ }
207
+ }
208
+
209
+ private settleQueuedCancellation(batch: Batch<T>): void {
169
210
  for (const entry of batch.entries) {
170
211
  if (entry.state === "queued") {
171
- entry.cancelRequested = true;
172
212
  this.settle(batch, entry, this.cancelOutcome(entry));
173
- } else if (entry.state === "running") {
174
- entry.cancelRequested = true;
175
- entry.controller?.abort();
176
213
  }
177
214
  }
178
215
  this.pump();
@@ -182,7 +219,7 @@ export class Scheduler<T> {
182
219
  for (const batch of this.batches) {
183
220
  for (const entry of batch.entries) {
184
221
  if (this.active.size >= this.limit) return;
185
- if (entry.state !== "queued") continue;
222
+ if (entry.state !== "queued" || entry.cancelRequested) continue;
186
223
  if (!this.prerequisitesSucceeded(batch, entry)) continue;
187
224
  if (this.hasActiveConflict(entry)) continue;
188
225
  this.start(batch, entry);
@@ -207,7 +244,7 @@ export class Scheduler<T> {
207
244
 
208
245
  private start(batch: Batch<T>, entry: Entry<T>): void {
209
246
  // Guarded start: a cancelled or settled queued closure never runs later.
210
- if (entry.state !== "queued") return;
247
+ if (entry.state !== "queued" || entry.cancelRequested) return;
211
248
  entry.state = "running";
212
249
  entry.attempts += 1;
213
250
  entry.controller = new AbortController();
@@ -262,7 +299,7 @@ export class Scheduler<T> {
262
299
  id: entry.request.id,
263
300
  status: "cancelled",
264
301
  attempts: entry.attempts,
265
- error: "batch cancelled",
302
+ error: errorMessage(entry.cancelReason ?? new Error("batch cancelled")),
266
303
  };
267
304
  }
268
305
 
@@ -31,6 +31,7 @@ export interface ScopedToolDef {
31
31
 
32
32
  interface Options {
33
33
  roots: string[];
34
+ allowedPaths?: string[];
34
35
  observer?: (obs: Observation) => void;
35
36
  }
36
37
 
@@ -38,7 +39,11 @@ type PathResult =
38
39
  | { ok: true; abs: string; rel: string }
39
40
  | { ok: false; error: string };
40
41
 
41
- function resolveScoped(roots: string[], input: unknown): PathResult {
42
+ function resolveScoped(
43
+ roots: string[],
44
+ input: unknown,
45
+ allowedPaths?: string[],
46
+ ): PathResult {
42
47
  if (typeof input !== "string" || input.length === 0) {
43
48
  return { ok: false, error: "missing path" };
44
49
  }
@@ -54,11 +59,21 @@ function resolveScoped(roots: string[], input: unknown): PathResult {
54
59
  if (input.startsWith("./") || input.includes("//") || input.endsWith("/.")) {
55
60
  return { ok: false, error: "noncanonical path rejected" };
56
61
  }
62
+ let insideRoot = false;
57
63
  for (const root of roots) {
58
64
  const abs = resolve(root, input);
59
65
  const inside =
60
66
  abs === root || abs.startsWith(root.endsWith("/") ? root : `${root}/`);
61
67
  if (!inside) continue;
68
+ insideRoot = true;
69
+ if (
70
+ allowedPaths &&
71
+ !allowedPaths.some(
72
+ (allowed) => abs === allowed || abs.startsWith(`${allowed}/`),
73
+ )
74
+ ) {
75
+ continue;
76
+ }
62
77
  // Hidden directories are rejected unless the root itself is the hidden scope.
63
78
  const hiddenRoot =
64
79
  basename(realpathSync(root)) === ".git" ||
@@ -89,7 +104,12 @@ function resolveScoped(roots: string[], input: unknown): PathResult {
89
104
  }
90
105
  return { ok: true, abs: abs, rel: relative(root, abs) || "." };
91
106
  }
92
- return { ok: false, error: "path is outside every approved root" };
107
+ return {
108
+ ok: false,
109
+ error: insideRoot
110
+ ? "path is outside the declared read/write scope"
111
+ : "path is outside every approved root",
112
+ };
93
113
  }
94
114
 
95
115
  function existsSync(p: string): boolean {
@@ -113,6 +133,7 @@ function utf8Text(abs: string): string | null {
113
133
 
114
134
  export function createScopedTools(opts: Options): ScopedToolDef[] {
115
135
  const roots = opts.roots.map((r) => resolve(r));
136
+ const allowedPaths = opts.allowedPaths?.map((p) => resolve(p));
116
137
  const observe = opts.observer ?? (() => {});
117
138
 
118
139
  const readTool: ScopedToolDef = {
@@ -120,7 +141,7 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
120
141
  description:
121
142
  "Read a regular UTF-8 text file within the approved scope (max 2,000 lines / 50 KiB).",
122
143
  async execute(params) {
123
- const resolved = resolveScoped(roots, params.path);
144
+ const resolved = resolveScoped(roots, params.path, allowedPaths);
124
145
  if (!resolved.ok) return { ok: false, error: resolved.error };
125
146
  let st: Stats | undefined;
126
147
  try {
@@ -167,7 +188,7 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
167
188
  error: `pattern exceeds ${TOOL_LIMITS.maxGrepPattern} characters`,
168
189
  };
169
190
  }
170
- const resolved = resolveScoped(roots, params.path ?? ".");
191
+ const resolved = resolveScoped(roots, params.path ?? ".", allowedPaths);
171
192
  if (!resolved.ok) return { ok: false, error: resolved.error };
172
193
  let regex: RegExp;
173
194
  try {
@@ -207,7 +228,7 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
207
228
  description:
208
229
  "List a directory within the approved scope (max 20,000 entries, stable code-unit order).",
209
230
  async execute(params) {
210
- const resolved = resolveScoped(roots, params.path ?? ".");
231
+ const resolved = resolveScoped(roots, params.path ?? ".", allowedPaths);
211
232
  if (!resolved.ok) return { ok: false, error: resolved.error };
212
233
  let st: Stats | undefined;
213
234
  try {
@@ -236,7 +257,7 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
236
257
  description:
237
258
  "Recursively list paths within the approved scope (max 20,000 entries, stable code-unit order).",
238
259
  async execute(params) {
239
- const resolved = resolveScoped(roots, params.path ?? ".");
260
+ const resolved = resolveScoped(roots, params.path ?? ".", allowedPaths);
240
261
  if (!resolved.ok) return { ok: false, error: resolved.error };
241
262
  let st: Stats | undefined;
242
263
  try {