@parall/cli 1.55.3 → 1.55.5

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.
@@ -28,6 +28,13 @@ export type RuntimeContext = {
28
28
  dispatchSourceId?: string;
29
29
  /** Trigger thread root (parel invocation context) — the by-source send's default thread. */
30
30
  threadRootId?: string;
31
+ /**
32
+ * Conversation generation of this execution (parel v2 invocation context,
33
+ * PRLL_GENERATION). Rides every dispatch_source send; the server rejects a
34
+ * mutation whose generation predates a New Session barrier. Absent on v1
35
+ * and lane-bound turns.
36
+ */
37
+ generation?: number;
31
38
  };
32
39
  /**
33
40
  * Read per-dispatch context from ENV + sideband files.
@@ -49,25 +56,36 @@ export declare function resolveRuntimeContext(): RuntimeContext;
49
56
  export declare function resolveSourceDispatchBinding(ctx: RuntimeContext, chatId: string, threadRootId: string | undefined): {
50
57
  source_type: string;
51
58
  source_id: string;
59
+ generation?: number;
52
60
  } | null;
53
61
  /** Typed dispatch binding for a task write, derived from the turn context. */
54
62
  export type TypedDispatchBinding = {
55
- lane: string;
63
+ /** Claimed typed lane (v1 consumers). Absent on the converged transport,
64
+ * where no lane exists and the write binds by dispatch_event_id under the
65
+ * generation fence instead. */
66
+ lane?: string;
56
67
  dispatchEventId: string;
68
+ /** Conversation generation (converged transport, PRLL_GENERATION). */
69
+ generation?: number;
57
70
  /** Set for the first (resolving) update of this dispatch. */
58
71
  effectKey?: string;
59
72
  markCommitted: () => void;
60
73
  };
61
74
  /**
62
- * Resolve the typed-lane binding for `parall task update <taskId>`: the
63
- * current turn must be a typed dispatch (lane target `dsp:<id>`) about this
64
- * exact task. The first update carries the canonical
75
+ * Resolve the typed dispatch binding for `parall task update <taskId>`: the
76
+ * current turn must be a typed dispatch about this exact task.
77
+ *
78
+ * Lane-bound (v1): the first update carries the canonical
65
79
  * `task_update:<dispatch_event_id>` effect key (resolves the WorkItem in the
66
- * same transaction); later updates go keyless (lane-checked only). The
67
- * committed flag lives in a CLI-owned sidecar keyed by the typed lane; when
80
+ * same transaction); later updates go keyless (lane-checked only), with the
81
+ * committed flag in a CLI-owned sidecar keyed by the typed lane. When
68
82
  * PRLL_CONTEXT_DIR is absent the sidecar can't exist, so no effect key is
69
83
  * ever attached (a blind key would make a *different* second update replay
70
84
  * the first one's result instead of applying).
85
+ *
86
+ * Converged (no lane): the caller appends a payload hash to the base key —
87
+ * content addressing gives every distinct update its own idempotent key, so
88
+ * no sidecar state is needed at all.
71
89
  */
72
90
  export declare function resolveTypedDispatchBinding(taskId: string): TypedDispatchBinding | null;
73
91
  /** Per-lane dispatch context (PRLL_CONTEXT_DIR contract), keyed by send target. */
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/lib/client.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,YAAY,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,6DAA6D;AAC7D,MAAM,MAAM,cAAc,GAAG;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,oGAAoG;IACpG,OAAO,EAAE,OAAO,CAAC;IACjB,8EAA8E;IAC9E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,sFAAsF;IACtF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4FAA4F;IAC5F,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,IAAI,cAAc,CAyDtD;AAED;;;;;;;GAOG;AACH,wBAAgB,4BAA4B,CAC1C,GAAG,EAAE,cAAc,EACnB,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,MAAM,GAAG,SAAS,GAC/B;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAKnD;AAED,8EAA8E;AAC9E,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,EAAE,MAAM,CAAC;IACxB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,IAAI,CAAC;CAC3B,CAAC;AAEF;;;;;;;;;;GAUG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,MAAM,GAAG,oBAAoB,GAAG,IAAI,CAwCvF;AAED,mFAAmF;AACnF,MAAM,MAAM,mBAAmB,GAAG;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6EAA6E;IAC7E,cAAc,EAAE,OAAO,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF;;;;GAIG;AACH,qBAAa,gBAAiB,SAAQ,KAAK;CAAG;AAE9C;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,MAAM,EACd,YAAY,CAAC,EAAE,MAAM,GACpB,mBAAmB,GAAG,IAAI,CA+E5B;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,mBAAmB,GAAG,IAAI,CAYzE;AAED,wBAAgB,kBAAkB,IAAI,mBAAmB,CA8BxD"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/lib/client.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,YAAY,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,6DAA6D;AAC7D,MAAM,MAAM,cAAc,GAAG;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,oGAAoG;IACpG,OAAO,EAAE,OAAO,CAAC;IACjB,8EAA8E;IAC9E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,sFAAsF;IACtF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4FAA4F;IAC5F,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,IAAI,cAAc,CAkEtD;AAED;;;;;;;GAOG;AACH,wBAAgB,4BAA4B,CAC1C,GAAG,EAAE,cAAc,EACnB,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,MAAM,GAAG,SAAS,GAC/B;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAWxE;AAED,8EAA8E;AAC9E,MAAM,MAAM,oBAAoB,GAAG;IACjC;;oCAEgC;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,EAAE,MAAM,CAAC;IACxB,sEAAsE;IACtE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,IAAI,CAAC;CAC3B,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,MAAM,GAAG,oBAAoB,GAAG,IAAI,CA8DvF;AAED,mFAAmF;AACnF,MAAM,MAAM,mBAAmB,GAAG;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6EAA6E;IAC7E,cAAc,EAAE,OAAO,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF;;;;GAIG;AACH,qBAAa,gBAAiB,SAAQ,KAAK;CAAG;AAE9C;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,MAAM,EACd,YAAY,CAAC,EAAE,MAAM,GACpB,mBAAmB,GAAG,IAAI,CA+E5B;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,mBAAmB,GAAG,IAAI,CAYzE;AAED,wBAAgB,kBAAkB,IAAI,mBAAmB,CA8BxD"}
@@ -18,10 +18,15 @@ export function resolveRuntimeContext() {
18
18
  const dispatchSourceType = process.env.PRLL_DISPATCH_SOURCE_TYPE?.trim() || undefined;
19
19
  const dispatchSourceId = process.env.PRLL_DISPATCH_SOURCE_ID?.trim() || undefined;
20
20
  const threadRootId = process.env.PRLL_THREAD_ROOT_ID?.trim() || undefined;
21
- let dispatchEventId;
22
- let lane;
23
- let laneTargetUri;
24
- let taskId;
21
+ const generationRaw = process.env.PRLL_GENERATION?.trim();
22
+ const generation = generationRaw && /^\d+$/.test(generationRaw) ? Number(generationRaw) : undefined;
23
+ // Parel v2 surfaces the lane context as plain env (there is no context
24
+ // file inside a parel sandbox); the CC/Codex context file below still wins
25
+ // per-field when both exist.
26
+ let dispatchEventId = process.env.PRLL_DISPATCH_EVENT_ID?.trim() || undefined;
27
+ let lane = process.env.PRLL_DISPATCH_LANE?.trim() || undefined;
28
+ let laneTargetUri = process.env.PRLL_LANE_TARGET_URI?.trim() || undefined;
29
+ let taskId = process.env.PRLL_TASK_ID?.trim() || undefined;
25
30
  if (process.env.PRLL_CONTEXT_FILE) {
26
31
  try {
27
32
  const raw = fs.readFileSync(process.env.PRLL_CONTEXT_FILE, 'utf-8').trim();
@@ -33,10 +38,12 @@ export function resolveRuntimeContext() {
33
38
  stepId ??= ctx.step_id || undefined;
34
39
  if (ctx.no_reply === true)
35
40
  noReply = true;
36
- dispatchEventId = ctx.dispatch_event_id || undefined;
37
- lane = ctx.lane || undefined;
38
- laneTargetUri = ctx.target_uri || undefined;
39
- taskId = ctx.task_id || undefined;
41
+ // The file wins per-field over the parel env fallbacks above, but an
42
+ // absent field must not erase them.
43
+ dispatchEventId = ctx.dispatch_event_id || dispatchEventId;
44
+ lane = ctx.lane || lane;
45
+ laneTargetUri = ctx.target_uri || laneTargetUri;
46
+ taskId = ctx.task_id || taskId;
40
47
  }
41
48
  }
42
49
  catch {
@@ -66,6 +73,7 @@ export function resolveRuntimeContext() {
66
73
  dispatchSourceType,
67
74
  dispatchSourceId,
68
75
  threadRootId,
76
+ generation,
69
77
  };
70
78
  }
71
79
  /**
@@ -83,36 +91,67 @@ export function resolveSourceDispatchBinding(ctx, chatId, threadRootId) {
83
91
  return null;
84
92
  if ((threadRootId ?? '') !== (ctx.threadRootId ?? ''))
85
93
  return null;
86
- return { source_type: ctx.dispatchSourceType, source_id: ctx.dispatchSourceId };
94
+ return {
95
+ source_type: ctx.dispatchSourceType,
96
+ source_id: ctx.dispatchSourceId,
97
+ // v2 executions carry their conversation generation; the server fences
98
+ // stale-generation replies inside the commit transaction.
99
+ ...(ctx.generation !== undefined ? { generation: ctx.generation } : {}),
100
+ };
87
101
  }
88
102
  /**
89
- * Resolve the typed-lane binding for `parall task update <taskId>`: the
90
- * current turn must be a typed dispatch (lane target `dsp:<id>`) about this
91
- * exact task. The first update carries the canonical
103
+ * Resolve the typed dispatch binding for `parall task update <taskId>`: the
104
+ * current turn must be a typed dispatch about this exact task.
105
+ *
106
+ * Lane-bound (v1): the first update carries the canonical
92
107
  * `task_update:<dispatch_event_id>` effect key (resolves the WorkItem in the
93
- * same transaction); later updates go keyless (lane-checked only). The
94
- * committed flag lives in a CLI-owned sidecar keyed by the typed lane; when
108
+ * same transaction); later updates go keyless (lane-checked only), with the
109
+ * committed flag in a CLI-owned sidecar keyed by the typed lane. When
95
110
  * PRLL_CONTEXT_DIR is absent the sidecar can't exist, so no effect key is
96
111
  * ever attached (a blind key would make a *different* second update replay
97
112
  * the first one's result instead of applying).
113
+ *
114
+ * Converged (no lane): the caller appends a payload hash to the base key —
115
+ * content addressing gives every distinct update its own idempotent key, so
116
+ * no sidecar state is needed at all.
98
117
  */
99
118
  export function resolveTypedDispatchBinding(taskId) {
100
119
  const ctx = resolveRuntimeContext();
101
- if (!ctx.lane || !ctx.dispatchEventId)
102
- return null;
103
- if (!ctx.laneTargetUri?.startsWith('dsp:'))
120
+ if (!ctx.dispatchEventId)
104
121
  return null;
105
122
  if (!ctx.taskId || ctx.taskId !== taskId)
106
123
  return null;
107
- const binding = {
108
- lane: ctx.lane,
109
- dispatchEventId: ctx.dispatchEventId,
110
- markCommitted: () => { },
111
- };
124
+ let binding;
125
+ let sidecarKey;
126
+ if (!ctx.lane) {
127
+ // Converged transport: no lane exists — the write binds by
128
+ // dispatch_event_id under the server's generation fence. A context with
129
+ // no generation must NOT produce a binding: an unfenced by-id write
130
+ // would bypass the New Session barrier (the server rejects it anyway —
131
+ // failing closed here keeps the task update itself working as a plain
132
+ // write). Content-addressed keys replace the reply-slot sidecar on this
133
+ // path: the caller appends a payload hash to the base key, so a
134
+ // same-payload retry replays its own key idempotently while a different
135
+ // second update mints a new key and lands as its own Effect.
136
+ if (ctx.generation === undefined)
137
+ return null;
138
+ return {
139
+ dispatchEventId: ctx.dispatchEventId,
140
+ generation: ctx.generation,
141
+ effectKey: `task_update:${ctx.dispatchEventId}`,
142
+ markCommitted: () => { },
143
+ };
144
+ }
145
+ // Lane-bound (v1 consumers): the write rides the claimed typed lane and
146
+ // the reply-slot sidecar arbitrates the single resolving key.
147
+ if (!ctx.laneTargetUri?.startsWith('dsp:'))
148
+ return null;
149
+ binding = { lane: ctx.lane, dispatchEventId: ctx.dispatchEventId, markCommitted: () => { } };
150
+ sidecarKey = ctx.laneTargetUri;
112
151
  const contextDir = process.env.PRLL_CONTEXT_DIR?.trim();
113
152
  if (!contextDir)
114
153
  return binding;
115
- const sidecarPath = laneReplyStateFilePath(contextDir, ctx.laneTargetUri);
154
+ const sidecarPath = laneReplyStateFilePath(contextDir, sidecarKey);
116
155
  let committed = false;
117
156
  try {
118
157
  const sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf-8'));
@@ -122,19 +161,22 @@ export function resolveTypedDispatchBinding(taskId) {
122
161
  catch {
123
162
  // No sidecar yet — resolving update not committed.
124
163
  }
125
- if (!committed) {
126
- const dispatchEventId = ctx.dispatchEventId;
127
- binding.effectKey = `task_update:${dispatchEventId}`;
128
- binding.markCommitted = () => {
129
- try {
130
- fs.writeFileSync(sidecarPath, JSON.stringify({ dispatch_event_id: dispatchEventId, reply_committed: true }), 'utf-8');
131
- }
132
- catch {
133
- // Best-effort — a lost sidecar means the next update retries the key
134
- // and the server's effect ledger replies idempotently.
135
- }
136
- };
164
+ if (committed) {
165
+ // Resolving update already landed: later updates go keyless.
166
+ delete binding.effectKey;
167
+ return binding;
137
168
  }
169
+ const dispatchEventId = ctx.dispatchEventId;
170
+ binding.effectKey = `task_update:${dispatchEventId}`;
171
+ binding.markCommitted = () => {
172
+ try {
173
+ fs.writeFileSync(sidecarPath, JSON.stringify({ dispatch_event_id: dispatchEventId, reply_committed: true }), 'utf-8');
174
+ }
175
+ catch {
176
+ // Best-effort — a lost sidecar means the next update retries the key
177
+ // and the server's effect ledger replies idempotently.
178
+ }
179
+ };
138
180
  return binding;
139
181
  }
140
182
  /**
@@ -21,5 +21,5 @@ export declare function ensurePrllScheme(idOrUri: string): string;
21
21
  * than receiving a malformed value.
22
22
  */
23
23
  export declare function parsePositiveInt(raw: string | undefined): number | undefined;
24
- export declare function printError(err: unknown): never;
24
+ export declare function printError(err: unknown, attemptedPayload?: Record<string, unknown>): never;
25
25
  //# sourceMappingURL=output.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../../src/lib/output.ts"],"names":[],"mappings":"AAEA,wBAAgB,SAAS,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,CAE7C;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,SAAY,GAAG,IAAI,CAEvE;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAW5E;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,KAAK,CAwC9C"}
1
+ {"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../../src/lib/output.ts"],"names":[],"mappings":"AAEA,wBAAgB,SAAS,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,CAE7C;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,SAAY,GAAG,IAAI,CAEvE;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAW5E;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,EAAE,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,KAAK,CA8C1F"}
@@ -43,7 +43,7 @@ export function parsePositiveInt(raw) {
43
43
  const n = Number(t);
44
44
  return Number.isInteger(n) && n > 0 ? n : undefined;
45
45
  }
46
- export function printError(err) {
46
+ export function printError(err, attemptedPayload) {
47
47
  if (err instanceof ApiError) {
48
48
  // Faithful, parseable line: the server's real message + machine anchors.
49
49
  console.error(JSON.stringify({
@@ -70,7 +70,13 @@ export function printError(err) {
70
70
  ? 'This action can be approved.'
71
71
  : 'This action may be approvable.';
72
72
  const chat = err.resourceUri?.startsWith('prll://cht_') ? err.resourceUri : '<chat_id>';
73
- console.error(`${lead} Request approval:\n parall approvals request --action ${err.action} --resource ${err.resourceUri ?? '<resource>'} --chat ${chat} --title "..." --reason "..."`);
73
+ // When the failing command knows exactly what it tried to write, the
74
+ // proposal command is prefilled with that payload — the deny → propose
75
+ // loop then needs no re-assembly by the caller.
76
+ const payloadArg = attemptedPayload
77
+ ? ` --payload '${JSON.stringify(attemptedPayload).replaceAll("'", `'\\''`)}'`
78
+ : '';
79
+ console.error(`${lead} Request approval:\n parall approvals request --action ${err.action} --resource ${err.resourceUri ?? '<resource>'}${payloadArg} --chat ${chat} --title "..." --reason "..."`);
74
80
  }
75
81
  }
76
82
  }
@@ -0,0 +1,29 @@
1
+ import { spawn } from 'node:child_process';
2
+ export declare const UPDATE_CHECK_TIMEOUT_MS = 1500;
3
+ type UpdateNotifierOptions = {
4
+ now?: () => number;
5
+ cachePath?: string;
6
+ spawnWorker?: (cachePath: string) => void;
7
+ writeHint?: (hint: string) => void;
8
+ disabled?: boolean;
9
+ };
10
+ type RefreshOptions = {
11
+ now?: () => number;
12
+ fetch?: typeof fetch;
13
+ timeoutMs?: number;
14
+ registryUrl?: string;
15
+ };
16
+ export type UpdateNotifier = {
17
+ afterSuccessfulCommand: () => void;
18
+ };
19
+ export declare function isNewerRelease(currentVersion: string, latestVersion: string): boolean;
20
+ /**
21
+ * Acquire the cache's cross-process lock. The token check prevents a stale
22
+ * owner from deleting a replacement lock when it eventually resumes.
23
+ */
24
+ export declare function acquireUpdateStateLock(cachePath: string, now?: () => number): (() => void) | null;
25
+ export declare function spawnUpdateCheckWorker(cachePath: string, spawnImpl?: typeof spawn): void;
26
+ export declare function createUpdateNotifier(currentVersion: string, options?: UpdateNotifierOptions): UpdateNotifier;
27
+ export declare function refreshUpdateCache(cachePath: string, options?: RefreshOptions): Promise<void>;
28
+ export {};
29
+ //# sourceMappingURL=update-check.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update-check.d.ts","sourceRoot":"","sources":["../../src/lib/update-check.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAW3C,eAAO,MAAM,uBAAuB,OAAQ,CAAC;AAS7C,KAAK,qBAAqB,GAAG;IAC3B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1C,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,KAAK,cAAc,GAAG;IACpB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,sBAAsB,EAAE,MAAM,IAAI,CAAC;CACpC,CAAC;AAQF,wBAAgB,cAAc,CAAC,cAAc,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAUrF;AA4CD;;;GAGG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,MAAM,EACjB,GAAG,GAAE,MAAM,MAAiB,GAC3B,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAkErB;AAgBD,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,GAAE,OAAO,KAAa,GAAG,IAAI,CAW/F;AAWD,wBAAgB,oBAAoB,CAClC,cAAc,EAAE,MAAM,EACtB,OAAO,GAAE,qBAA0B,GAClC,cAAc,CAyDhB;AAED,wBAAsB,kBAAkB,CACtC,SAAS,EAAE,MAAM,EACjB,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CA+Bf"}
@@ -0,0 +1,269 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import * as fs from 'node:fs';
4
+ import * as os from 'node:os';
5
+ import * as path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ const REGISTRY_URL = 'https://registry.npmjs.org/@parall%2Fcli/latest';
8
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
9
+ const RETRY_INTERVAL_MS = 60 * 60 * 1000;
10
+ const LOCK_STALE_MS = 30_000;
11
+ export const UPDATE_CHECK_TIMEOUT_MS = 1_500;
12
+ function releaseParts(version) {
13
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
14
+ if (!match)
15
+ return null;
16
+ return [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])];
17
+ }
18
+ export function isNewerRelease(currentVersion, latestVersion) {
19
+ const current = releaseParts(currentVersion);
20
+ const latest = releaseParts(latestVersion);
21
+ if (!current || !latest)
22
+ return false;
23
+ for (let i = 0; i < current.length; i++) {
24
+ if (latest[i] > current[i])
25
+ return true;
26
+ if (latest[i] < current[i])
27
+ return false;
28
+ }
29
+ return false;
30
+ }
31
+ function defaultCachePath() {
32
+ const envRoot = process.env.XDG_CACHE_HOME?.trim();
33
+ const cacheRoot = envRoot ||
34
+ (process.platform === 'win32' && process.env.LOCALAPPDATA?.trim()) ||
35
+ path.join(os.homedir(), '.cache');
36
+ return path.join(cacheRoot, 'parall', 'update-check.json');
37
+ }
38
+ function readState(cachePath) {
39
+ try {
40
+ const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
41
+ const state = {};
42
+ if (typeof parsed.latest_version === 'string')
43
+ state.latest_version = parsed.latest_version;
44
+ if (typeof parsed.last_success_at === 'number')
45
+ state.last_success_at = parsed.last_success_at;
46
+ if (typeof parsed.last_attempt_at === 'number')
47
+ state.last_attempt_at = parsed.last_attempt_at;
48
+ if (typeof parsed.last_notified_at === 'number') {
49
+ state.last_notified_at = parsed.last_notified_at;
50
+ }
51
+ return state;
52
+ }
53
+ catch {
54
+ return {};
55
+ }
56
+ }
57
+ function writeState(cachePath, state) {
58
+ const tempPath = `${cachePath}.${process.pid}.tmp`;
59
+ try {
60
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true, mode: 0o700 });
61
+ fs.writeFileSync(tempPath, `${JSON.stringify(state)}\n`, { encoding: 'utf8', mode: 0o600 });
62
+ fs.renameSync(tempPath, cachePath);
63
+ return true;
64
+ }
65
+ catch {
66
+ try {
67
+ fs.unlinkSync(tempPath);
68
+ }
69
+ catch {
70
+ // Best-effort cleanup only.
71
+ }
72
+ return false;
73
+ }
74
+ }
75
+ /**
76
+ * Acquire the cache's cross-process lock. The token check prevents a stale
77
+ * owner from deleting a replacement lock when it eventually resumes.
78
+ */
79
+ export function acquireUpdateStateLock(cachePath, now = Date.now) {
80
+ const lockPath = `${cachePath}.lock`;
81
+ const withTokenGate = (token, action) => {
82
+ const tokenHash = createHash('sha256').update(token).digest('hex');
83
+ const gatePath = `${lockPath}.reclaim-${tokenHash}`;
84
+ const gateToken = `${process.pid}:${randomUUID()}`;
85
+ try {
86
+ fs.writeFileSync(gatePath, gateToken, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
87
+ }
88
+ catch {
89
+ return undefined;
90
+ }
91
+ try {
92
+ return action();
93
+ }
94
+ finally {
95
+ try {
96
+ if (fs.readFileSync(gatePath, 'utf8') === gateToken)
97
+ fs.unlinkSync(gatePath);
98
+ }
99
+ catch {
100
+ // A lost/replaced gate is already released from this owner's perspective.
101
+ }
102
+ }
103
+ };
104
+ const tryAcquire = () => {
105
+ const token = `${process.pid}:${randomUUID()}`;
106
+ try {
107
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true, mode: 0o700 });
108
+ fs.writeFileSync(lockPath, token, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ return () => {
114
+ withTokenGate(token, () => {
115
+ try {
116
+ if (fs.readFileSync(lockPath, 'utf8') === token)
117
+ fs.unlinkSync(lockPath);
118
+ }
119
+ catch {
120
+ // A lost/replaced lock is already released from this owner's perspective.
121
+ }
122
+ });
123
+ };
124
+ };
125
+ const release = tryAcquire();
126
+ if (release)
127
+ return release;
128
+ let staleToken;
129
+ try {
130
+ staleToken = fs.readFileSync(lockPath, 'utf8');
131
+ if (now() - fs.statSync(lockPath).mtimeMs < LOCK_STALE_MS)
132
+ return null;
133
+ }
134
+ catch (err) {
135
+ return err.code === 'ENOENT' ? tryAcquire() : null;
136
+ }
137
+ return (withTokenGate(staleToken, () => {
138
+ try {
139
+ if (fs.readFileSync(lockPath, 'utf8') !== staleToken)
140
+ return null;
141
+ if (now() - fs.statSync(lockPath).mtimeMs < LOCK_STALE_MS)
142
+ return null;
143
+ fs.unlinkSync(lockPath);
144
+ }
145
+ catch (err) {
146
+ if (err.code !== 'ENOENT')
147
+ return null;
148
+ }
149
+ return tryAcquire();
150
+ }) ?? null);
151
+ }
152
+ function withUpdateStateLock(cachePath, now, update) {
153
+ const release = acquireUpdateStateLock(cachePath, now);
154
+ if (!release)
155
+ return undefined;
156
+ try {
157
+ return update();
158
+ }
159
+ finally {
160
+ release();
161
+ }
162
+ }
163
+ export function spawnUpdateCheckWorker(cachePath, spawnImpl = spawn) {
164
+ const workerPath = fileURLToPath(new URL('../update-check-worker.js', import.meta.url));
165
+ const child = spawnImpl(process.execPath, [workerPath, cachePath], {
166
+ detached: true,
167
+ stdio: 'ignore',
168
+ windowsHide: true,
169
+ });
170
+ child.on('error', () => {
171
+ // A missing/broken Node binary must not turn a completed CLI command into a failure.
172
+ });
173
+ child.unref();
174
+ }
175
+ function updateChecksDisabled() {
176
+ const ci = process.env.CI?.trim().toLowerCase();
177
+ return (process.env.PRLL_DISABLE_UPDATE_CHECK === '1' ||
178
+ process.env.NO_UPDATE_NOTIFIER === '1' ||
179
+ Boolean(ci && ci !== '0' && ci !== 'false'));
180
+ }
181
+ export function createUpdateNotifier(currentVersion, options = {}) {
182
+ const now = options.now ?? Date.now;
183
+ const cachePath = options.cachePath ?? defaultCachePath();
184
+ const spawnWorker = options.spawnWorker ?? spawnUpdateCheckWorker;
185
+ const writeHint = options.writeHint ?? ((hint) => process.stderr.write(hint));
186
+ const disabled = options.disabled ?? updateChecksDisabled();
187
+ return {
188
+ afterSuccessfulCommand: () => {
189
+ if (disabled || !releaseParts(currentVersion))
190
+ return;
191
+ const timestamp = now();
192
+ const decision = withUpdateStateLock(cachePath, now, () => {
193
+ const state = readState(cachePath);
194
+ let hint;
195
+ let changed = false;
196
+ if (state.latest_version &&
197
+ isNewerRelease(currentVersion, state.latest_version) &&
198
+ timestamp - (state.last_notified_at ?? 0) >= CHECK_INTERVAL_MS) {
199
+ hint =
200
+ `Update available: parall ${currentVersion} → ${state.latest_version}. ` +
201
+ 'Upgrade @parall/cli@latest with your package manager.\n';
202
+ state.last_notified_at = timestamp;
203
+ changed = true;
204
+ }
205
+ const successfulCheckIsFresh = timestamp - (state.last_success_at ?? 0) < CHECK_INTERVAL_MS;
206
+ const recentAttempt = timestamp - (state.last_attempt_at ?? 0) < RETRY_INTERVAL_MS;
207
+ const shouldSpawnWorker = !successfulCheckIsFresh && !recentAttempt;
208
+ if (shouldSpawnWorker) {
209
+ state.last_attempt_at = timestamp;
210
+ changed = true;
211
+ }
212
+ if (changed && !writeState(cachePath, state))
213
+ return undefined;
214
+ return { hint, shouldSpawnWorker };
215
+ });
216
+ if (!decision)
217
+ return;
218
+ if (decision.hint) {
219
+ try {
220
+ writeHint(decision.hint);
221
+ }
222
+ catch {
223
+ // A closed output stream must not change the command's exit status.
224
+ }
225
+ }
226
+ if (!decision.shouldSpawnWorker)
227
+ return;
228
+ try {
229
+ spawnWorker(cachePath);
230
+ }
231
+ catch {
232
+ // Update checks never affect the command that triggered them.
233
+ }
234
+ },
235
+ };
236
+ }
237
+ export async function refreshUpdateCache(cachePath, options = {}) {
238
+ const fetchImpl = options.fetch ?? fetch;
239
+ const timeoutMs = options.timeoutMs ?? UPDATE_CHECK_TIMEOUT_MS;
240
+ const controller = new AbortController();
241
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
242
+ try {
243
+ const response = await fetchImpl(options.registryUrl ?? REGISTRY_URL, {
244
+ headers: { accept: 'application/json' },
245
+ signal: controller.signal,
246
+ });
247
+ if (!response.ok)
248
+ return;
249
+ const body = (await response.json());
250
+ if (typeof body.version !== 'string' || !releaseParts(body.version))
251
+ return;
252
+ const latestVersion = body.version;
253
+ const now = options.now ?? Date.now;
254
+ withUpdateStateLock(cachePath, now, () => {
255
+ const state = readState(cachePath);
256
+ writeState(cachePath, {
257
+ ...state,
258
+ latest_version: latestVersion,
259
+ last_success_at: now(),
260
+ });
261
+ });
262
+ }
263
+ catch {
264
+ // Network, timeout, registry, and cache failures are intentionally silent.
265
+ }
266
+ finally {
267
+ clearTimeout(timer);
268
+ }
269
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../src/lib/upload.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA6BhD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGtD;AAED,wBAAsB,UAAU,CAC9B,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAyBzF"}
1
+ {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../src/lib/upload.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA6BhD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGtD;AAED,wBAAsB,UAAU,CAC9B,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAgFzF"}
@@ -39,16 +39,71 @@ export async function uploadFile(client, orgId, filePath) {
39
39
  file_name: fileName,
40
40
  file_size: stat.size,
41
41
  mime_type: mimeType,
42
+ multipart_supported: true,
42
43
  });
43
- const fileBuffer = fs.readFileSync(resolved);
44
- const uploadRes = await fetch(presign.upload_url, {
45
- method: 'PUT',
46
- body: fileBuffer,
47
- headers: { 'Content-Type': mimeType },
48
- });
49
- if (!uploadRes.ok) {
50
- throw new Error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
44
+ let completionStarted = false;
45
+ try {
46
+ let completedParts;
47
+ if (presign.upload_type === 'multipart') {
48
+ if (!presign.part_size || !presign.parts?.length) {
49
+ throw new Error('Server returned an invalid multipart upload response');
50
+ }
51
+ const file = await fs.promises.open(resolved, 'r');
52
+ try {
53
+ completedParts = [];
54
+ const orderedParts = [...presign.parts].sort((a, b) => a.part_number - b.part_number);
55
+ for (const part of orderedParts) {
56
+ const position = (part.part_number - 1) * presign.part_size;
57
+ const length = Math.min(presign.part_size, stat.size - position);
58
+ if (length <= 0) {
59
+ throw new Error(`Upload part ${part.part_number} is outside the file bounds`);
60
+ }
61
+ const buffer = Buffer.allocUnsafe(length);
62
+ let bytesRead = 0;
63
+ while (bytesRead < length) {
64
+ const result = await file.read(buffer, bytesRead, length - bytesRead, position + bytesRead);
65
+ if (result.bytesRead === 0)
66
+ throw new Error('Unexpected end of file');
67
+ bytesRead += result.bytesRead;
68
+ }
69
+ const uploadRes = await fetch(part.upload_url, {
70
+ method: 'PUT',
71
+ body: buffer,
72
+ headers: { 'Content-Type': mimeType },
73
+ });
74
+ if (!uploadRes.ok) {
75
+ throw new Error(`Upload part ${part.part_number} failed: ${uploadRes.status} ${uploadRes.statusText}`);
76
+ }
77
+ const etag = uploadRes.headers.get('ETag');
78
+ if (!etag)
79
+ throw new Error(`Upload part ${part.part_number} returned no ETag`);
80
+ completedParts.push({ part_number: part.part_number, etag });
81
+ }
82
+ }
83
+ finally {
84
+ await file.close();
85
+ }
86
+ }
87
+ else {
88
+ if (!presign.upload_url)
89
+ throw new Error('Server did not return an upload URL');
90
+ const fileBuffer = fs.readFileSync(resolved);
91
+ const uploadRes = await fetch(presign.upload_url, {
92
+ method: 'PUT',
93
+ body: fileBuffer,
94
+ headers: { 'Content-Type': mimeType },
95
+ });
96
+ if (!uploadRes.ok) {
97
+ throw new Error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
98
+ }
99
+ }
100
+ completionStarted = true;
101
+ await client.completeUpload(orgId, presign.attachment_id, completedParts);
102
+ }
103
+ catch (error) {
104
+ if (!completionStarted)
105
+ await client.abortUpload(orgId, presign.attachment_id).catch(() => { });
106
+ throw error;
51
107
  }
52
- await client.completeUpload(orgId, presign.attachment_id);
53
108
  return { attachmentId: presign.attachment_id, fileName, fileSize: stat.size, mimeType };
54
109
  }