ugly-app 0.1.809 → 0.1.810

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.
@@ -1,2 +1,2 @@
1
- export declare const CLI_VERSION = "0.1.809";
1
+ export declare const CLI_VERSION = "0.1.810";
2
2
  //# sourceMappingURL=version.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.809";
2
+ export const CLI_VERSION = "0.1.810";
3
3
  //# sourceMappingURL=version.js.map
@@ -28,6 +28,30 @@ export interface TaskFacade {
28
28
  params?: unknown;
29
29
  id?: string;
30
30
  }): Promise<TaskHandle>;
31
+ /**
32
+ * Idempotent upsert of a background task by stable `id`. Reuses the running task
33
+ * when its bundle (`entry`) matches; otherwise replaces it — stops the stale/dead
34
+ * one, starts fresh, and waits until it is ready — then returns the handle. Use
35
+ * this instead of hand-rolling enum→compare→stop→start: it also absorbs the host
36
+ * stop→start-same-id exit race (a killed child's async `exit`, keyed by id, can
37
+ * otherwise clobber the freshly started task to 'exited'). `reused` is false when
38
+ * a NEW child was started — signalling the caller to re-establish per-task
39
+ * `listen()` subscriptions (a replaced task drops the old host listener).
40
+ */
41
+ ensure(opts: {
42
+ id: string;
43
+ entry: string;
44
+ kind?: string;
45
+ label?: string;
46
+ params?: unknown;
47
+ /** Max wait for the fresh task to report ready (default 20s). */
48
+ readyTimeoutMs?: number;
49
+ /** Readiness poll interval (default 100ms). */
50
+ pollMs?: number;
51
+ }): Promise<{
52
+ handle: TaskHandle;
53
+ reused: boolean;
54
+ }>;
31
55
  /** List this app's background tasks (origin-scoped on the host). */
32
56
  enum(): Promise<TaskInfo[]>;
33
57
  /** Get a handle to an already-running task by id without spawning. */
@@ -52,6 +76,6 @@ type Subscribe = (event: string, cb: (data: unknown) => void) => () => void;
52
76
  * listen→ subscribe('task.event:'+id) (host fans events out as { event, data })
53
77
  * Assembled client-side so it's identical local (desktop) or proxied (mobile/web).
54
78
  */
55
- export declare function createTaskFacade(invoke: Invoke, subscribe: Subscribe, ensure?: () => Promise<void>): TaskFacade;
79
+ export declare function createTaskFacade(invoke: Invoke, subscribe: Subscribe, whenReady?: () => Promise<void>): TaskFacade;
56
80
  export {};
57
81
  //# sourceMappingURL=task.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"task.d.ts","sourceRoot":"","sources":["../../../src/native/facades/task.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE/C;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAGnF;AAED,2EAA2E;AAC3E,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,+FAA+F;IAC/F,MAAM,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IACpE,0FAA0F;IAC1F,IAAI,CAAC,CAAC,GAAG,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACjE,gEAAgE;IAChE,IAAI,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,UAAU;IACzB,yFAAyF;IACzF,KAAK,CAAC,IAAI,EAAE;QACV,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,EAAE,CAAC,EAAE,MAAM,CAAC;KACb,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IACxB,oEAAoE;IACpE,IAAI,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC;IAC5B,sEAAsE;IACtE,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,UAAU,CAAC;IAC/B,oCAAoC;IACpC,IAAI,CAAC,CAAC,GAAG,OAAO,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IAC7E,0EAA0E;IAC1E,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IAChF,yBAAyB;IACzB,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACjD;AAKD,KAAK,MAAM,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AACtE,KAAK,SAAS,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;AAE5E;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,UAAU,CAgC/G"}
1
+ {"version":3,"file":"task.d.ts","sourceRoot":"","sources":["../../../src/native/facades/task.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE/C;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAGnF;AAED,2EAA2E;AAC3E,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,+FAA+F;IAC/F,MAAM,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IACpE,0FAA0F;IAC1F,IAAI,CAAC,CAAC,GAAG,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACjE,gEAAgE;IAChE,IAAI,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,UAAU;IACzB,yFAAyF;IACzF,KAAK,CAAC,IAAI,EAAE;QACV,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,EAAE,CAAC,EAAE,MAAM,CAAC;KACb,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IACxB;;;;;;;;;OASG;IACH,MAAM,CAAC,IAAI,EAAE;QACX,EAAE,EAAE,MAAM,CAAC;QACX,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,iEAAiE;QACjE,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,+CAA+C;QAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,UAAU,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IACrD,oEAAoE;IACpE,IAAI,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC;IAC5B,sEAAsE;IACtE,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,UAAU,CAAC;IAC/B,oCAAoC;IACpC,IAAI,CAAC,CAAC,GAAG,OAAO,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IAC7E,0EAA0E;IAC1E,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IAChF,yBAAyB;IACzB,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACjD;AAKD,KAAK,MAAM,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AACtE,KAAK,SAAS,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;AAE5E;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,UAAU,CA6ElH"}
@@ -18,13 +18,31 @@ export function taskEntryUrl(name, buildId, origin) {
18
18
  * listen→ subscribe('task.event:'+id) (host fans events out as { event, data })
19
19
  * Assembled client-side so it's identical local (desktop) or proxied (mobile/web).
20
20
  */
21
- export function createTaskFacade(invoke, subscribe, ensure) {
21
+ export function createTaskFacade(invoke, subscribe, whenReady) {
22
22
  const listen = (id, onEvent) => subscribe(`task.event:${id}`, (d) => {
23
23
  const f = (d ?? {});
24
24
  onEvent(f.event ?? 'event', f.data);
25
25
  });
26
26
  const call = (id, method, payload) => invoke('task.call', { id, method, payload });
27
27
  const stop = (id) => invoke('task.stop', { id });
28
+ const enumTasks = async () => (await invoke('task.enum', undefined)).tasks;
29
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
+ // Poll enum until the task reports 'running'. Throws on a terminal 'exited'/'error'
31
+ // (a genuine startup failure — or, transiently, the stop→start clobber the caller
32
+ // retries past) and on timeout.
33
+ const waitRunning = async (id, timeoutMs, pollMs) => {
34
+ const deadline = Date.now() + timeoutMs;
35
+ for (;;) {
36
+ const t = (await enumTasks()).find((x) => x.id === id);
37
+ if (t?.status === 'running')
38
+ return;
39
+ if (t && (t.status === 'exited' || t.status === 'error'))
40
+ throw new Error(`task failed to start: ${t.status}`);
41
+ if (Date.now() >= deadline)
42
+ throw new Error('task did not become ready in time');
43
+ await sleep(pollMs);
44
+ }
45
+ };
28
46
  const handle = (id) => ({
29
47
  id,
30
48
  listen: (onEvent) => listen(id, onEvent),
@@ -33,13 +51,51 @@ export function createTaskFacade(invoke, subscribe, ensure) {
33
51
  });
34
52
  return {
35
53
  async start(opts) {
36
- if (ensure)
37
- await ensure();
54
+ if (whenReady)
55
+ await whenReady();
38
56
  const r = (await invoke('task.start', opts));
39
57
  return handle(r.id);
40
58
  },
59
+ async ensure(opts) {
60
+ const { id, entry, kind, label, params } = opts;
61
+ const readyTimeoutMs = opts.readyTimeoutMs ?? 20_000;
62
+ const pollMs = opts.pollMs ?? 100;
63
+ const existing = (await enumTasks()).find((t) => t.id === id);
64
+ const alive = !!existing && existing.status !== 'exited' && existing.status !== 'error';
65
+ // Reuse a healthy task whose bundle matches. `entry` pins the buildId, so a
66
+ // task left from a prior deploy (different entry) is never reused.
67
+ if (alive && existing.entry === entry)
68
+ return { handle: handle(id), reused: true };
69
+ // Replace. Stop→start on the SAME id races the host: `stop` kills the old child
70
+ // asynchronously and its late `exit` (keyed by id) can fire AFTER the fresh task
71
+ // registers, clobbering it to 'exited'. So stop, let the old child die (settle),
72
+ // then start; retry with a growing settle if a slow-dying child still clobbers.
73
+ if (existing) {
74
+ try {
75
+ await stop(id);
76
+ }
77
+ catch { /* already gone */ }
78
+ }
79
+ const settles = alive ? [350, 900, 1800] : [0];
80
+ let lastErr;
81
+ for (const settleMs of settles) {
82
+ if (settleMs > 0)
83
+ await sleep(settleMs);
84
+ if (whenReady)
85
+ await whenReady();
86
+ await invoke('task.start', { id, entry, kind, label, params });
87
+ try {
88
+ await waitRunning(id, readyTimeoutMs, pollMs);
89
+ return { handle: handle(id), reused: false };
90
+ }
91
+ catch (e) {
92
+ lastErr = e; // likely the clobber — retry after a longer settle
93
+ }
94
+ }
95
+ throw lastErr;
96
+ },
41
97
  async enum() {
42
- return (await invoke('task.enum', undefined)).tasks;
98
+ return enumTasks();
43
99
  },
44
100
  attach: (id) => handle(id),
45
101
  call,
@@ -1 +1 @@
1
- {"version":3,"file":"task.js","sourceRoot":"","sources":["../../../src/native/facades/task.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,MAAM,UAAU,YAAY,CAAC,IAAY,EAAE,OAAe,EAAE,MAAe;IACzE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,OAAO,QAAQ,KAAK,WAAW,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IAChF,OAAO,GAAG,IAAI,IAAI,OAAO,UAAU,IAAI,KAAK,CAAC;AAC/C,CAAC;AAwCD;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAAc,EAAE,SAAoB,EAAE,MAA4B;IACjG,MAAM,MAAM,GAAG,CAAC,EAAU,EAAE,OAA+C,EAAgB,EAAE,CAC3F,SAAS,CAAC,cAAc,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE;QAClC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAuC,CAAC;QAC1D,OAAO,CAAC,CAAC,CAAC,KAAK,IAAI,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC;IACtC,CAAC,CAAC,CAAC;IACL,MAAM,IAAI,GAAG,CAAI,EAAU,EAAE,MAAc,EAAE,OAAiB,EAAc,EAAE,CAC5E,MAAM,CAAC,WAAW,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,CAAe,CAAC;IAC7D,MAAM,IAAI,GAAG,CAAC,EAAU,EAAiC,EAAE,CACzD,MAAM,CAAC,WAAW,EAAE,EAAE,EAAE,EAAE,CAAkC,CAAC;IAE/D,MAAM,MAAM,GAAG,CAAC,EAAU,EAAc,EAAE,CAAC,CAAC;QAC1C,EAAE;QACF,MAAM,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,EAAE,OAAO,CAAC;QACxC,IAAI,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC;QACpD,IAAI,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;KACrB,CAAC,CAAC;IAEH,OAAO;QACL,KAAK,CAAC,KAAK,CAAC,IAAI;YACd,IAAI,MAAM;gBAAE,MAAM,MAAM,EAAE,CAAC;YAC3B,MAAM,CAAC,GAAG,CAAC,MAAM,MAAM,CAAC,YAAY,EAAE,IAAI,CAAC,CAAmB,CAAC;YAC/D,OAAO,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QACtB,CAAC;QACD,KAAK,CAAC,IAAI;YACR,OAAQ,CAAC,MAAM,MAAM,CAAC,WAAW,EAAE,SAAS,CAAC,CAA2B,CAAC,KAAK,CAAC;QACjF,CAAC;QACD,MAAM,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC;QAC1B,IAAI;QACJ,MAAM;QACN,IAAI;KACL,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"task.js","sourceRoot":"","sources":["../../../src/native/facades/task.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,MAAM,UAAU,YAAY,CAAC,IAAY,EAAE,OAAe,EAAE,MAAe;IACzE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,OAAO,QAAQ,KAAK,WAAW,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IAChF,OAAO,GAAG,IAAI,IAAI,OAAO,UAAU,IAAI,KAAK,CAAC;AAC/C,CAAC;AA6DD;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAAc,EAAE,SAAoB,EAAE,SAA+B;IACpG,MAAM,MAAM,GAAG,CAAC,EAAU,EAAE,OAA+C,EAAgB,EAAE,CAC3F,SAAS,CAAC,cAAc,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE;QAClC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAuC,CAAC;QAC1D,OAAO,CAAC,CAAC,CAAC,KAAK,IAAI,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC;IACtC,CAAC,CAAC,CAAC;IACL,MAAM,IAAI,GAAG,CAAI,EAAU,EAAE,MAAc,EAAE,OAAiB,EAAc,EAAE,CAC5E,MAAM,CAAC,WAAW,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,CAAe,CAAC;IAC7D,MAAM,IAAI,GAAG,CAAC,EAAU,EAAiC,EAAE,CACzD,MAAM,CAAC,WAAW,EAAE,EAAE,EAAE,EAAE,CAAkC,CAAC;IAC/D,MAAM,SAAS,GAAG,KAAK,IAAyB,EAAE,CAC/C,CAAC,MAAM,MAAM,CAAC,WAAW,EAAE,SAAS,CAAC,CAA2B,CAAC,KAAK,CAAC;IAC1E,MAAM,KAAK,GAAG,CAAC,EAAU,EAAiB,EAAE,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;IACnF,oFAAoF;IACpF,kFAAkF;IAClF,gCAAgC;IAChC,MAAM,WAAW,GAAG,KAAK,EAAE,EAAU,EAAE,SAAiB,EAAE,MAAc,EAAiB,EAAE;QACzF,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC;QACxC,SAAS,CAAC;YACR,MAAM,CAAC,GAAG,CAAC,MAAM,SAAS,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;YACvD,IAAI,CAAC,EAAE,MAAM,KAAK,SAAS;gBAAE,OAAO;YACpC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,KAAK,OAAO,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;YAC/G,IAAI,IAAI,CAAC,GAAG,EAAE,IAAI,QAAQ;gBAAE,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;YACjF,MAAM,KAAK,CAAC,MAAM,CAAC,CAAC;QACtB,CAAC;IACH,CAAC,CAAC;IAEF,MAAM,MAAM,GAAG,CAAC,EAAU,EAAc,EAAE,CAAC,CAAC;QAC1C,EAAE;QACF,MAAM,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,EAAE,OAAO,CAAC;QACxC,IAAI,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC;QACpD,IAAI,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;KACrB,CAAC,CAAC;IAEH,OAAO;QACL,KAAK,CAAC,KAAK,CAAC,IAAI;YACd,IAAI,SAAS;gBAAE,MAAM,SAAS,EAAE,CAAC;YACjC,MAAM,CAAC,GAAG,CAAC,MAAM,MAAM,CAAC,YAAY,EAAE,IAAI,CAAC,CAAmB,CAAC;YAC/D,OAAO,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QACtB,CAAC;QACD,KAAK,CAAC,MAAM,CAAC,IAAI;YACf,MAAM,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;YAChD,MAAM,cAAc,GAAG,IAAI,CAAC,cAAc,IAAI,MAAM,CAAC;YACrD,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,IAAI,GAAG,CAAC;YAClC,MAAM,QAAQ,GAAG,CAAC,MAAM,SAAS,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;YAC9D,MAAM,KAAK,GAAG,CAAC,CAAC,QAAQ,IAAI,QAAQ,CAAC,MAAM,KAAK,QAAQ,IAAI,QAAQ,CAAC,MAAM,KAAK,OAAO,CAAC;YACxF,4EAA4E;YAC5E,mEAAmE;YACnE,IAAI,KAAK,IAAI,QAAQ,CAAC,KAAK,KAAK,KAAK;gBAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;YACnF,gFAAgF;YAChF,iFAAiF;YACjF,iFAAiF;YACjF,gFAAgF;YAChF,IAAI,QAAQ,EAAE,CAAC;gBAAC,IAAI,CAAC;oBAAC,MAAM,IAAI,CAAC,EAAE,CAAC,CAAC;gBAAC,CAAC;gBAAC,MAAM,CAAC,CAAC,kBAAkB,CAAC,CAAC;YAAC,CAAC;YACtE,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YAC/C,IAAI,OAAgB,CAAC;YACrB,KAAK,MAAM,QAAQ,IAAI,OAAO,EAAE,CAAC;gBAC/B,IAAI,QAAQ,GAAG,CAAC;oBAAE,MAAM,KAAK,CAAC,QAAQ,CAAC,CAAC;gBACxC,IAAI,SAAS;oBAAE,MAAM,SAAS,EAAE,CAAC;gBACjC,MAAM,MAAM,CAAC,YAAY,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;gBAC/D,IAAI,CAAC;oBACH,MAAM,WAAW,CAAC,EAAE,EAAE,cAAc,EAAE,MAAM,CAAC,CAAC;oBAC9C,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;gBAC/C,CAAC;gBAAC,OAAO,CAAC,EAAE,CAAC;oBACX,OAAO,GAAG,CAAC,CAAC,CAAC,mDAAmD;gBAClE,CAAC;YACH,CAAC;YACD,MAAM,OAAO,CAAC;QAChB,CAAC;QACD,KAAK,CAAC,IAAI;YACR,OAAO,SAAS,EAAE,CAAC;QACrB,CAAC;QACD,MAAM,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC;QAC1B,IAAI;QACJ,MAAM;QACN,IAAI;KACL,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-app",
3
- "version": "0.1.809",
3
+ "version": "0.1.810",
4
4
  "type": "module",
5
5
  "comment:files": "Allowlist what ships to npm. dist = runtime; src = sourcemap targets (dist/*.js.map reference ../../src/); templates = CLI scaffold. Everything else at repo root (.pgdata local Postgres, coverage, assets/icons sources, test/, test-results/) is excluded by omission. The !negations strip the scaffold's installed deps + cruft (templates/node_modules is 200MB+ and must never ship). package.json/README/LICENSE ship automatically.",
6
6
  "files": [
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.809";
2
+ export const CLI_VERSION = "0.1.810";
@@ -72,4 +72,66 @@ describe('createTaskFacade', () => {
72
72
  expect(invoke).toHaveBeenCalledWith('task.call', { id: 'task_9', method: 'ping', payload: undefined });
73
73
  expect(invoke).toHaveBeenCalledWith('task.stop', { id: 'task_9' });
74
74
  });
75
+
76
+ const rec = (over: Partial<{ status: string; entry: string }>) => ({
77
+ id: 'coding:s', kind: 'coding', label: 'x', origin: 'o', startedAt: 1,
78
+ status: 'running', entry: 'E1', ...over,
79
+ });
80
+
81
+ it('ensure reuses a running task whose entry matches (no stop/start)', async () => {
82
+ const invoke = vi.fn(async (ch: string) =>
83
+ ch === 'task.enum' ? { tasks: [rec({ entry: 'E1' })] } : undefined);
84
+ const r = await createTaskFacade(invoke, vi.fn()).ensure({ id: 'coding:s', entry: 'E1', kind: 'coding' });
85
+ expect(r.reused).toBe(true);
86
+ expect(r.handle.id).toBe('coding:s');
87
+ expect(invoke).not.toHaveBeenCalledWith('task.stop', expect.anything());
88
+ expect(invoke).not.toHaveBeenCalledWith('task.start', expect.anything());
89
+ });
90
+
91
+ it('ensure replaces a stale-entry task: stop old, start fresh, reused=false', async () => {
92
+ let started = false;
93
+ const invoke = vi.fn(async (ch: string) => {
94
+ if (ch === 'task.enum') return { tasks: [rec({ entry: started ? 'E2' : 'E1' })] };
95
+ if (ch === 'task.stop') return { stopped: true };
96
+ if (ch === 'task.start') { started = true; return { id: 'coding:s' }; }
97
+ return undefined;
98
+ });
99
+ const r = await createTaskFacade(invoke, vi.fn())
100
+ .ensure({ id: 'coding:s', entry: 'E2', kind: 'coding', readyTimeoutMs: 2000, pollMs: 1 });
101
+ expect(r.reused).toBe(false);
102
+ expect(invoke).toHaveBeenCalledWith('task.stop', { id: 'coding:s' });
103
+ expect(invoke).toHaveBeenCalledWith('task.start', expect.objectContaining({ id: 'coding:s', entry: 'E2' }));
104
+ });
105
+
106
+ it('ensure starts fresh when no task exists, without stopping', async () => {
107
+ let started = false;
108
+ const invoke = vi.fn(async (ch: string) => {
109
+ if (ch === 'task.enum') return { tasks: started ? [rec({ entry: 'E1' })] : [] };
110
+ if (ch === 'task.start') { started = true; return { id: 'coding:s' }; }
111
+ return undefined;
112
+ });
113
+ const r = await createTaskFacade(invoke, vi.fn()).ensure({ id: 'coding:s', entry: 'E1', pollMs: 1 });
114
+ expect(r.reused).toBe(false);
115
+ expect(invoke).not.toHaveBeenCalledWith('task.stop', expect.anything());
116
+ });
117
+
118
+ it('ensure retries past a transient exited (the stop→start clobber race)', async () => {
119
+ let starts = 0;
120
+ const invoke = vi.fn(async (ch: string) => {
121
+ if (ch === 'task.enum') {
122
+ // stale E1 until first start; the first fresh task is clobbered to 'exited'
123
+ // (race), the second start lands 'running'.
124
+ if (starts === 0) return { tasks: [rec({ entry: 'E1' })] };
125
+ if (starts === 1) return { tasks: [rec({ entry: 'E2', status: 'exited' })] };
126
+ return { tasks: [rec({ entry: 'E2', status: 'running' })] };
127
+ }
128
+ if (ch === 'task.stop') return { stopped: true };
129
+ if (ch === 'task.start') { starts += 1; return { id: 'coding:s' }; }
130
+ return undefined;
131
+ });
132
+ const r = await createTaskFacade(invoke, vi.fn())
133
+ .ensure({ id: 'coding:s', entry: 'E2', kind: 'coding', readyTimeoutMs: 5000, pollMs: 1 });
134
+ expect(r.reused).toBe(false);
135
+ expect(starts).toBe(2); // clobbered once, succeeded on retry
136
+ });
75
137
  });
@@ -32,6 +32,27 @@ export interface TaskFacade {
32
32
  params?: unknown;
33
33
  id?: string;
34
34
  }): Promise<TaskHandle>;
35
+ /**
36
+ * Idempotent upsert of a background task by stable `id`. Reuses the running task
37
+ * when its bundle (`entry`) matches; otherwise replaces it — stops the stale/dead
38
+ * one, starts fresh, and waits until it is ready — then returns the handle. Use
39
+ * this instead of hand-rolling enum→compare→stop→start: it also absorbs the host
40
+ * stop→start-same-id exit race (a killed child's async `exit`, keyed by id, can
41
+ * otherwise clobber the freshly started task to 'exited'). `reused` is false when
42
+ * a NEW child was started — signalling the caller to re-establish per-task
43
+ * `listen()` subscriptions (a replaced task drops the old host listener).
44
+ */
45
+ ensure(opts: {
46
+ id: string;
47
+ entry: string;
48
+ kind?: string;
49
+ label?: string;
50
+ params?: unknown;
51
+ /** Max wait for the fresh task to report ready (default 20s). */
52
+ readyTimeoutMs?: number;
53
+ /** Readiness poll interval (default 100ms). */
54
+ pollMs?: number;
55
+ }): Promise<{ handle: TaskHandle; reused: boolean }>;
35
56
  /** List this app's background tasks (origin-scoped on the host). */
36
57
  enum(): Promise<TaskInfo[]>;
37
58
  /** Get a handle to an already-running task by id without spawning. */
@@ -59,7 +80,7 @@ type Subscribe = (event: string, cb: (data: unknown) => void) => () => void;
59
80
  * listen→ subscribe('task.event:'+id) (host fans events out as { event, data })
60
81
  * Assembled client-side so it's identical local (desktop) or proxied (mobile/web).
61
82
  */
62
- export function createTaskFacade(invoke: Invoke, subscribe: Subscribe, ensure?: () => Promise<void>): TaskFacade {
83
+ export function createTaskFacade(invoke: Invoke, subscribe: Subscribe, whenReady?: () => Promise<void>): TaskFacade {
63
84
  const listen = (id: string, onEvent: (event: string, data: unknown) => void): (() => void) =>
64
85
  subscribe(`task.event:${id}`, (d) => {
65
86
  const f = (d ?? {}) as { event?: string; data?: unknown };
@@ -69,6 +90,22 @@ export function createTaskFacade(invoke: Invoke, subscribe: Subscribe, ensure?:
69
90
  invoke('task.call', { id, method, payload }) as Promise<R>;
70
91
  const stop = (id: string): Promise<{ stopped: boolean }> =>
71
92
  invoke('task.stop', { id }) as Promise<{ stopped: boolean }>;
93
+ const enumTasks = async (): Promise<TaskInfo[]> =>
94
+ ((await invoke('task.enum', undefined)) as { tasks: TaskInfo[] }).tasks;
95
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
96
+ // Poll enum until the task reports 'running'. Throws on a terminal 'exited'/'error'
97
+ // (a genuine startup failure — or, transiently, the stop→start clobber the caller
98
+ // retries past) and on timeout.
99
+ const waitRunning = async (id: string, timeoutMs: number, pollMs: number): Promise<void> => {
100
+ const deadline = Date.now() + timeoutMs;
101
+ for (;;) {
102
+ const t = (await enumTasks()).find((x) => x.id === id);
103
+ if (t?.status === 'running') return;
104
+ if (t && (t.status === 'exited' || t.status === 'error')) throw new Error(`task failed to start: ${t.status}`);
105
+ if (Date.now() >= deadline) throw new Error('task did not become ready in time');
106
+ await sleep(pollMs);
107
+ }
108
+ };
72
109
 
73
110
  const handle = (id: string): TaskHandle => ({
74
111
  id,
@@ -79,12 +116,41 @@ export function createTaskFacade(invoke: Invoke, subscribe: Subscribe, ensure?:
79
116
 
80
117
  return {
81
118
  async start(opts) {
82
- if (ensure) await ensure();
119
+ if (whenReady) await whenReady();
83
120
  const r = (await invoke('task.start', opts)) as { id: string };
84
121
  return handle(r.id);
85
122
  },
123
+ async ensure(opts) {
124
+ const { id, entry, kind, label, params } = opts;
125
+ const readyTimeoutMs = opts.readyTimeoutMs ?? 20_000;
126
+ const pollMs = opts.pollMs ?? 100;
127
+ const existing = (await enumTasks()).find((t) => t.id === id);
128
+ const alive = !!existing && existing.status !== 'exited' && existing.status !== 'error';
129
+ // Reuse a healthy task whose bundle matches. `entry` pins the buildId, so a
130
+ // task left from a prior deploy (different entry) is never reused.
131
+ if (alive && existing.entry === entry) return { handle: handle(id), reused: true };
132
+ // Replace. Stop→start on the SAME id races the host: `stop` kills the old child
133
+ // asynchronously and its late `exit` (keyed by id) can fire AFTER the fresh task
134
+ // registers, clobbering it to 'exited'. So stop, let the old child die (settle),
135
+ // then start; retry with a growing settle if a slow-dying child still clobbers.
136
+ if (existing) { try { await stop(id); } catch { /* already gone */ } }
137
+ const settles = alive ? [350, 900, 1800] : [0];
138
+ let lastErr: unknown;
139
+ for (const settleMs of settles) {
140
+ if (settleMs > 0) await sleep(settleMs);
141
+ if (whenReady) await whenReady();
142
+ await invoke('task.start', { id, entry, kind, label, params });
143
+ try {
144
+ await waitRunning(id, readyTimeoutMs, pollMs);
145
+ return { handle: handle(id), reused: false };
146
+ } catch (e) {
147
+ lastErr = e; // likely the clobber — retry after a longer settle
148
+ }
149
+ }
150
+ throw lastErr;
151
+ },
86
152
  async enum() {
87
- return ((await invoke('task.enum', undefined)) as { tasks: TaskInfo[] }).tasks;
153
+ return enumTasks();
88
154
  },
89
155
  attach: (id) => handle(id),
90
156
  call,