@sigrun/sigrun 0.6.0 → 0.7.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 (64) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +105 -18
  3. package/dist/blocks/build-stamp.json +2 -2
  4. package/dist/blocks/schema.js +5 -0
  5. package/dist/cli.js +85 -22
  6. package/dist/curation/host.js +46 -21
  7. package/dist/curation/migrate.js +96 -0
  8. package/dist/curation/overlays.js +52 -16
  9. package/dist/curation/paths.js +22 -0
  10. package/dist/curation/runs.js +34 -17
  11. package/dist/curation/skill-tool.js +34 -9
  12. package/dist/curation/store.js +382 -254
  13. package/dist/curation/targets.js +21 -0
  14. package/dist/curation/wrap.js +1 -6
  15. package/dist/extension.d.ts +12 -3
  16. package/dist/harness/boot.js +143 -6
  17. package/dist/harness/event-bus.js +19 -0
  18. package/dist/harness/loader.js +22 -0
  19. package/dist/harness/packages.js +7 -0
  20. package/dist/harness/reserved-sections.js +2 -0
  21. package/dist/harness/seam-host.js +71 -16
  22. package/dist/harness/workspace.js +34 -1
  23. package/dist/history/git.js +707 -0
  24. package/dist/history/trailers.js +145 -0
  25. package/dist/history/workspace-history.js +47 -0
  26. package/dist/jobs/index.js +5 -3
  27. package/dist/jobs/reconcile.js +2 -3
  28. package/dist/jobs/state-dir.js +9 -0
  29. package/dist/jobs/transfer.js +6 -6
  30. package/dist/sandbox/grants.js +11 -0
  31. package/dist/sandbox/launch.js +19 -9
  32. package/dist/sandbox/policy.js +6 -1
  33. package/dist/serve.js +3 -0
  34. package/dist/server/auth/proxy-only.js +5 -4
  35. package/dist/server/core-views/index.js +26 -0
  36. package/dist/server/core-views/jobs.js +661 -0
  37. package/dist/server/core-views/skills-tree.js +628 -0
  38. package/dist/server/core-views/skills.js +1090 -0
  39. package/dist/server/index.js +88 -0
  40. package/dist/server/jobs-access.js +62 -0
  41. package/dist/server/notifications.js +24 -12
  42. package/dist/server/routes/extensions.js +9 -1
  43. package/dist/server/routes/jobs.js +19 -52
  44. package/dist/server/routes/meta.js +9 -0
  45. package/dist/server/routes/onboarding.js +13 -1
  46. package/dist/server/surfaces/service.js +38 -0
  47. package/dist/skills/index.js +1 -1
  48. package/dist/skills/roots.js +301 -0
  49. package/node_modules/@sigrun/shared/CHANGELOG.md +13 -0
  50. package/node_modules/@sigrun/shared/dist/auth.d.ts +22 -0
  51. package/node_modules/@sigrun/shared/dist/auth.js +26 -0
  52. package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +1 -1
  53. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +15 -0
  54. package/node_modules/@sigrun/shared/dist/model-label.d.ts +29 -0
  55. package/node_modules/@sigrun/shared/dist/model-label.js +55 -0
  56. package/node_modules/@sigrun/shared/dist/views.d.ts +28 -0
  57. package/node_modules/@sigrun/shared/package.json +9 -1
  58. package/package.json +7 -7
  59. package/webui/dist/assets/index-BI5k-Oci.js +55 -0
  60. package/webui/dist/assets/index-EGluyRXk.css +1 -0
  61. package/webui/dist/favicon.svg +7 -5
  62. package/webui/dist/index.html +3 -3
  63. package/webui/dist/assets/index-BR0di0OG.css +0 -1
  64. package/webui/dist/assets/index-BzrA1T4g.js +0 -55
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Commit trailers for sigrun-owned git history (tabs+skills plan, Task 7).
3
+ *
4
+ * The feed reads action/skill from trailers, never by parsing the summary —
5
+ * the exact key set is the contract: Sigrun-Actor, Sigrun-Action,
6
+ * Sigrun-Skill, Sigrun-Review, Sigrun-Conversation, Sigrun-Reason,
7
+ * Sigrun-Undoes.
8
+ *
9
+ * Author identity rule (plan Global Constraints): author name = actor
10
+ * (`<user>` | `curator` | `agent` | `outside sigrun`), author email
11
+ * `<actor-slug>@sigrun.invalid` (`outside-sigrun@…`); the committer is always
12
+ * `sigrun <sigrun@sigrun.invalid>` (set by git.ts at commit time).
13
+ */
14
+ export function actorName(actor) {
15
+ switch (actor.kind) {
16
+ case "user":
17
+ return actor.user;
18
+ case "curator":
19
+ return "curator";
20
+ case "agent":
21
+ return "agent";
22
+ case "outside":
23
+ return "outside sigrun";
24
+ }
25
+ }
26
+ export function actorEmail(actor) {
27
+ let raw;
28
+ if (actor.kind === "user")
29
+ raw = actor.user;
30
+ else if (actor.kind === "outside")
31
+ raw = "outside-sigrun";
32
+ else
33
+ raw = actor.kind;
34
+ // The sigrun slug rule (src/curation/names.ts): lowercase, map every
35
+ // run of non-slug characters (spaces included) to one dash, trim edge
36
+ // dashes. A name that slugs to nothing falls back to "user".
37
+ const slug = raw
38
+ .toLowerCase()
39
+ .replace(/[^a-z0-9-]+/g, "-")
40
+ .replace(/^-+|-+$/g, "");
41
+ return `${slug === "" ? "user" : slug}@sigrun.invalid`;
42
+ }
43
+ export function decodeActor(value) {
44
+ if (value === "curator")
45
+ return { kind: "curator" };
46
+ if (value === "agent")
47
+ return { kind: "agent" };
48
+ if (value === "outside sigrun")
49
+ return { kind: "outside" };
50
+ return { kind: "user", user: value };
51
+ }
52
+ const TRAILER_KEY_LINE = /^Sigrun-(Actor|Action|Skill|Review|Conversation|Reason|Undoes):/;
53
+ /**
54
+ * Sanitize a string before it enters the commit message. Blank-line
55
+ * sequences collapse to a space: git's trailer block starts at the last
56
+ * blank-line-separated paragraph, so an embedded "\n\nSigrun-Actor: …"
57
+ * would otherwise decode as trusted metadata. Lines shaped like a trailer
58
+ * key are flattened into the text.
59
+ */
60
+ export function sanitizeField(value) {
61
+ return value
62
+ .split("\n")
63
+ .map((line) => {
64
+ if (!TRAILER_KEY_LINE.test(line.trimStart()))
65
+ return line;
66
+ return line.replace(":", " -");
67
+ })
68
+ .join(" ")
69
+ .replace(/\s+/g, " ")
70
+ .trim();
71
+ }
72
+ /** Every CommitMeta string that flows into the commit message. */
73
+ export function sanitizeMeta(meta) {
74
+ const clean = (value) => value === undefined ? undefined : value.replace(/[\r\n]+/g, " ").trim();
75
+ const summary = sanitizeField(meta.summary);
76
+ return {
77
+ actor: meta.actor,
78
+ summary: summary === "" ? "(no summary)" : summary,
79
+ action: clean(meta.action),
80
+ skill: clean(meta.skill),
81
+ reviewId: clean(meta.reviewId),
82
+ conversationId: meta.conversationId,
83
+ reason: clean(meta.reason),
84
+ undoes: clean(meta.undoes),
85
+ };
86
+ }
87
+ export function encodeTrailers(meta) {
88
+ const lines = [`Sigrun-Actor: ${actorName(meta.actor)}`];
89
+ if (meta.action !== undefined)
90
+ lines.push(`Sigrun-Action: ${meta.action}`);
91
+ if (meta.skill !== undefined)
92
+ lines.push(`Sigrun-Skill: ${meta.skill}`);
93
+ if (meta.reviewId !== undefined)
94
+ lines.push(`Sigrun-Review: ${meta.reviewId}`);
95
+ if (meta.conversationId !== undefined) {
96
+ lines.push(`Sigrun-Conversation: ${meta.conversationId}`);
97
+ }
98
+ if (meta.reason !== undefined)
99
+ lines.push(`Sigrun-Reason: ${meta.reason}`);
100
+ if (meta.undoes !== undefined)
101
+ lines.push(`Sigrun-Undoes: ${meta.undoes}`);
102
+ return lines.join("\n");
103
+ }
104
+ function trailer(message, key) {
105
+ const value = new RegExp(`^${key}:\\s*(.+)$`, "m").exec(message)?.[1]?.trim();
106
+ return value === undefined || value === "" ? undefined : value;
107
+ }
108
+ /**
109
+ * Decode one commit's metadata from `git show --format=fuller` output (the
110
+ * shape the tests produce with a plain `git show`; GitHistory.log parses its
111
+ * own record format instead). `paths` is always empty here — fuller output
112
+ * carries no diff.
113
+ */
114
+ export function decodeCommit(raw) {
115
+ const sha = /^commit ([0-9a-f]{40})/m.exec(raw)?.[1];
116
+ if (sha === undefined)
117
+ throw new Error("decodeCommit: no commit header found");
118
+ const authorName = /^Author:\s+(.*?)\s+<[^>]+>/m.exec(raw)?.[1] ?? "";
119
+ const authorDate = /^AuthorDate:\s+(.+)$/m.exec(raw)?.[1]?.trim();
120
+ const messageStart = raw.indexOf("\n\n");
121
+ const message = messageStart === -1
122
+ ? ""
123
+ : raw
124
+ .slice(messageStart + 2)
125
+ .split("\n")
126
+ .map((line) => (line.startsWith(" ") ? line.slice(4) : line))
127
+ .join("\n")
128
+ .replace(/\n+$/, "");
129
+ const summary = message.split("\n").find((line) => line.trim() !== "") ?? "";
130
+ const conversationRaw = trailer(message, "Sigrun-Conversation");
131
+ const conversationId = conversationRaw === undefined ? undefined : Number(conversationRaw);
132
+ return {
133
+ sha,
134
+ at: authorDate === undefined ? "" : new Date(authorDate).toISOString(),
135
+ summary,
136
+ actor: decodeActor(trailer(message, "Sigrun-Actor") ?? authorName),
137
+ action: trailer(message, "Sigrun-Action"),
138
+ skill: trailer(message, "Sigrun-Skill"),
139
+ reviewId: trailer(message, "Sigrun-Review"),
140
+ conversationId,
141
+ reason: trailer(message, "Sigrun-Reason"),
142
+ undoes: trailer(message, "Sigrun-Undoes"),
143
+ paths: [],
144
+ };
145
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Workspace skills history (tabs+skills plan, Task 10): the SECOND
3
+ * per-user repo — `<workspace>/skills` under git, caught up on every
4
+ * settled run with actor `agent` and the Sigrun-Conversation trailer.
5
+ *
6
+ * The git dir lives under the user's curation history root
7
+ * (`<userArea>/curation/history/workspace.git`), NEVER inside the work
8
+ * tree — the work tree is a live user directory the agent edits, so the
9
+ * layout itself (not an info/exclude line) keeps the repo out of the
10
+ * staged content. Pathspec `["."]` with `add --all --force` stages the
11
+ * whole skills tree, including deletions of tracked files whose pathspec
12
+ * root no longer exists on disk (Task 7's shipped "exists on disk OR
13
+ * tracked" staging rule).
14
+ */
15
+ import { join } from "node:path";
16
+ import { historyDir } from "../curation/paths.js";
17
+ /**
18
+ * The workspace skills repo layout: gitDir
19
+ * `<userArea>/curation/history/workspace.git`, workTree
20
+ * `<workspace>/skills`, pathspec `["."]`.
21
+ */
22
+ export function workspaceRepoSpec(userArea, workspace) {
23
+ return {
24
+ gitDir: join(historyDir(userArea), "workspace.git"),
25
+ workTree: join(workspace, "skills"),
26
+ pathspec: ["."],
27
+ };
28
+ }
29
+ /**
30
+ * The user's workspace skills history, cached per (user, workspace
31
+ * path): a changed workspace path yields a NEW instance on the SAME
32
+ * gitDir, so the history follows the user across workspace moves — one
33
+ * log, one commit per tree. `workspace` undefined (the workspaceRoot
34
+ * rules' refusals) resolves to no history.
35
+ */
36
+ export function workspaceHistoryFor(user, workspace, deps) {
37
+ if (workspace === undefined)
38
+ return undefined;
39
+ // NUL-separated: no (user, workspace) pair can collide on a boundary.
40
+ const key = `${user}\0${workspace}`;
41
+ const cached = deps.cache.get(key);
42
+ if (cached !== undefined)
43
+ return cached;
44
+ const history = deps.create(workspace);
45
+ deps.cache.set(key, history);
46
+ return history;
47
+ }
@@ -28,7 +28,7 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
28
28
  import { JobDoc, listRevisions, listRuns, pruneHistory, readJob, scanJobs, writeJobChange, } from "./docs.js";
29
29
  import { fireJob } from "./fire.js";
30
30
  import { reconcileJobs, } from "./reconcile.js";
31
- import { jobStateDir, removeJobState } from "./state-dir.js";
31
+ import { removeJobState, warnJobStateRemoval } from "./state-dir.js";
32
32
  import { specHash } from "./spec.js";
33
33
  /** Thrown when an API write targets a `file:`/`ext:` job id (409 `job_declared`). */
34
34
  export class JobDeclaredError extends Error {
@@ -92,8 +92,7 @@ export function createJobsService(deps) {
92
92
  await removeJobState(root, id);
93
93
  }
94
94
  catch (error) {
95
- const path = root === undefined ? id : jobStateDir(root, id);
96
- console.warn(`jobs: could not remove job state ${path}: ${error instanceof Error ? error.message : String(error)}`);
95
+ warnJobStateRemoval("job state", root, id, error);
97
96
  }
98
97
  };
99
98
  // `job.changed` observation (W4 Task 4): ONE commit subscription over
@@ -240,6 +239,9 @@ export function createJobsService(deps) {
240
239
  // failure to its caller; it never rewrites or retires the job.
241
240
  await removeJobState(stateRoot(record.owner), id);
242
241
  },
242
+ jobStateRoot(owner) {
243
+ return stateRoot(owner);
244
+ },
243
245
  async runNow(id) {
244
246
  // ONE clock reading: the manual fire key and the run's fireAt must
245
247
  // agree — the settle path re-derives the run-record key from
@@ -38,7 +38,7 @@
38
38
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
39
39
  import { JobDoc, patchJob, scanJobs, writeJobChange, } from "./docs.js";
40
40
  import { nextFireAt } from "./schedule.js";
41
- import { jobStateDir, removeJobState } from "./state-dir.js";
41
+ import { removeJobState, warnJobStateRemoval } from "./state-dir.js";
42
42
  import { specHash } from "./spec.js";
43
43
  import { createScheduleTask } from "./task.js";
44
44
  const SCAN_PAGE = 500;
@@ -159,8 +159,7 @@ export async function reconcileJobs(deps, ctx = BACKGROUND_CONTEXT) {
159
159
  await removeJobState(root, id);
160
160
  }
161
161
  catch (error) {
162
- const path = root === undefined ? id : jobStateDir(root, id);
163
- console.warn(`jobs: could not remove retired job state ${path}: ${error instanceof Error ? error.message : String(error)}`);
162
+ warnJobStateRemoval("retired job state", root, id, error);
164
163
  }
165
164
  }
166
165
  // ── Pass 2: records → tasks ─────────────────────────────────────
@@ -14,3 +14,12 @@ export async function removeJobState(root, jobId) {
14
14
  return;
15
15
  await rm(jobStateDir(root, jobId), { recursive: true, force: true });
16
16
  }
17
+ /**
18
+ * The one best-effort removal warning (index delete, reconcile retire,
19
+ * transfer catch-path): the path the caller actually resolved, or the
20
+ * bare id when the selected owner has no jobs root.
21
+ */
22
+ export function warnJobStateRemoval(what, root, jobId, error) {
23
+ const path = root === undefined ? jobId : jobStateDir(root, jobId);
24
+ console.warn(`jobs: could not remove ${what} ${path}: ${error instanceof Error ? error.message : String(error)}`);
25
+ }
@@ -17,10 +17,9 @@
17
17
  * ownerless until the source is edited — spec §8.3's posture).
18
18
  */
19
19
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
20
- import { join } from "node:path";
21
20
  import { registerOwnershipParticipant } from "../server/ownership.js";
22
21
  import { scanJobs, writeJobChange } from "./docs.js";
23
- import { jobStateDir } from "./state-dir.js";
22
+ import { warnJobStateRemoval } from "./state-dir.js";
24
23
  import { specHash } from "./spec.js";
25
24
  /**
26
25
  * Register the jobs transfer participant on `ctx` (createServer calls
@@ -44,11 +43,12 @@ export function registerJobsOwnershipParticipant(ctx) {
44
43
  await jobs.resetState(id);
45
44
  }
46
45
  catch (error) {
47
- // Directory-user api jobs use their managed user area. (OS-user
48
- // jobs cannot enter this transfer path — user deletion refuses
46
+ // Name the root resetState actually resolved (the workspace
47
+ // service's selection), never a guessed layout. (OS-user jobs
48
+ // cannot enter this transfer path — user deletion refuses
49
49
  // osUser workspaces before any participant runs.)
50
- const root = join(ctx.managedPaths.userAreas(from), "jobs");
51
- console.warn(`jobs: could not remove transferred job state ${jobStateDir(root, id)}: ${error instanceof Error ? error.message : String(error)}`);
50
+ const root = jobs.jobStateRoot(from);
51
+ warnJobStateRemoval("transferred job state", root, id, error);
52
52
  }
53
53
  const spec = { ...record.spec, owner: to };
54
54
  const next = {
@@ -131,6 +131,17 @@ export function validateGrantPath(path, ctx) {
131
131
  (inheritedArea === undefined || !inside(candidate, inheritedArea))) {
132
132
  return { ok: false, reason: "path is inside another user's area" };
133
133
  }
134
+ // The curation dir is out of the agent's reach (spec §5): no
135
+ // workspace or grant may sit inside it, and none may CONTAIN it —
136
+ // a stored path equal to the bare user area would hand the agent
137
+ // the whole curation tree. Task 9 puts git history under
138
+ // curation/history.
139
+ for (const area of [ownArea, ...(inheritedArea === undefined ? [] : [inheritedArea])]) {
140
+ const curation = join(area, "curation");
141
+ if (inside(candidate, curation) || inside(curation, candidate)) {
142
+ return { ok: false, reason: "path overlaps the user's curation dir" };
143
+ }
144
+ }
134
145
  }
135
146
  return { ok: true, realpath: candidate };
136
147
  }
@@ -1,4 +1,22 @@
1
1
  /** The shared helper launch shape for worker and per-user service processes. */
2
+ /**
3
+ * Serialize a built policy into the `SIGRUN_SANDBOX_POLICY` env payload
4
+ * the sandbox helper parses (sandbox/src/policy.rs — `deny_unknown_fields`,
5
+ * so the field set here IS the wire contract). The single writer: every
6
+ * producer of a policy payload — `launchInJail`, the smoke-image jailed
7
+ * check — must build it here so the shapes cannot drift.
8
+ */
9
+ export function serializePolicy(policy) {
10
+ return JSON.stringify({
11
+ ro: policy.ro,
12
+ rw: policy.rw,
13
+ ensureDirs: policy.ensureDirs,
14
+ minAbi: 6,
15
+ ...(policy.uid === undefined ? {} : { uid: policy.uid }),
16
+ ...(policy.gid === undefined ? {} : { gid: policy.gid }),
17
+ ...(policy.groups === undefined ? {} : { groups: policy.groups }),
18
+ });
19
+ }
2
20
  /** Apply a complete policy in the helper, then exec the requested sibling process. */
3
21
  export function launchInJail(helper, policy, command, args, env) {
4
22
  return {
@@ -17,15 +35,7 @@ export function launchInJail(helper, policy, command, args, env) {
17
35
  // is not granted inside the jail, so honoring it would be the
18
36
  // same fatal startup error.
19
37
  OPENSSL_CONF: `${policy.tmp}/openssl.cnf`,
20
- SIGRUN_SANDBOX_POLICY: JSON.stringify({
21
- ro: policy.ro,
22
- rw: policy.rw,
23
- ensureDirs: policy.ensureDirs,
24
- minAbi: 6,
25
- ...(policy.uid === undefined ? {} : { uid: policy.uid }),
26
- ...(policy.gid === undefined ? {} : { gid: policy.gid }),
27
- ...(policy.groups === undefined ? {} : { groups: policy.groups }),
28
- }),
38
+ SIGRUN_SANDBOX_POLICY: serializePolicy(policy),
29
39
  },
30
40
  };
31
41
  }
@@ -98,7 +98,12 @@ export function defaultModuleTrees(coreRoot) {
98
98
  export function defaultPackageConfig(coreRoot) {
99
99
  return join(coreRoot, "package.json");
100
100
  }
101
- function validateUserPath(path, record, managed, inheritedFrom) {
101
+ /**
102
+ * One per-user path against the grant validator (Task 10 exports it:
103
+ * the workspace history's `workspaceRoot` resolves the SAME precedence
104
+ * as `buildUserPolicy` and must refuse the same paths).
105
+ */
106
+ export function validateUserPath(path, record, managed, inheritedFrom) {
102
107
  const candidate = canonical(path) ?? resolve(path);
103
108
  if (inside(candidate, "/dev/shm") || inside("/dev/shm", candidate)) {
104
109
  return { ok: false, reason: "path is or contains /dev/shm" };
package/dist/serve.js CHANGED
@@ -228,6 +228,9 @@ export async function serve(env, options) {
228
228
  extensionEntries: extensionResolutions,
229
229
  env,
230
230
  listenHost: host,
231
+ // Polish A1 (spec §5.2): serve goes on to createServer — bridge
232
+ // calls in the pre-bind window keep the bounded wait.
233
+ expectHostBridge: true,
231
234
  });
232
235
  // The gateway handle (gateway C2) exists BEFORE the server: the meta
233
236
  // route reaches it through ServerContext, so the engine's health is
@@ -13,11 +13,12 @@
13
13
  * deployments keep the setup-code/invite paths (token callers exist),
14
14
  * and loopback binds keep them too (loopback is its own trust).
15
15
  */
16
+ import { isProxyOnly } from "@sigrun/shared/auth";
16
17
  import { isLoopbackHost } from "./loopback.js";
17
- /** Whether the configured auth methods are exactly proxy-only. */
18
- export function isProxyOnly(methods) {
19
- return methods.includes("proxy") && !methods.includes("token");
20
- }
18
+ // Re-export: the predicate lives in @sigrun/shared (polish task 12 —
19
+ // one definition for the server, the webui, and the CLI); this module
20
+ // keeps the server's import path stable for its existing callers.
21
+ export { isProxyOnly };
21
22
  /**
22
23
  * Whether this boot requires a proxy vouch for account creation:
23
24
  * proxy-only AND bound beyond loopback. The loopback test is the shared
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Core-owned views (tabs+skills Task 4): the reserved `core` slug's
3
+ * built-in view surfaces (the Jobs and Skills views, attached by Tasks
4
+ * 5 and 12 via `SurfaceService.attachCoreViews`).
5
+ *
6
+ * Core views are NOT extensions: they have no outcome row, so the
7
+ * health dot is unaffected, and the `/v1/extensions` + `/v1/meta`
8
+ * routes list them under `coreViews`, never `extensions[]`. Rendering,
9
+ * data, actions, and instance-key/rev machinery run through the SAME
10
+ * `SurfaceService` code path as extension surfaces — this module only
11
+ * owns the wire projection.
12
+ */
13
+ /**
14
+ * The wire rows for a list of core-view defs: one {@link CoreViewInfo}
15
+ * per def, in attachment order (the channels list core views FIRST,
16
+ * ahead of extension views). `refreshMs` is sparse — absent when the
17
+ * def declares no cadence.
18
+ */
19
+ export function coreViewInfos(defs) {
20
+ return defs.map((def) => ({
21
+ id: def.id,
22
+ title: def.title,
23
+ icon: def.icon,
24
+ ...(def.refreshMs === undefined ? {} : { refreshMs: def.refreshMs }),
25
+ }));
26
+ }