hammerkit 1.27.0-next.8 → 2.0.0-next.1

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 (129) hide show
  1. package/build.schema.json +176 -6
  2. package/dist/cache/backends/local-cache-backend.js +6 -0
  3. package/dist/cache/backends/registry/registry-client.js +1 -0
  4. package/dist/cache/cache-sync.js +1 -1
  5. package/dist/cache/explain.js +17 -0
  6. package/dist/cli.js +2 -0
  7. package/dist/dashboard/assets/index-CjnKm5-3.css +2 -0
  8. package/dist/dashboard/assets/index-zPXa4klZ.js +13 -0
  9. package/dist/dashboard/favicon.svg +1 -0
  10. package/dist/dashboard/index.html +15 -0
  11. package/dist/docker/item-network.js +13 -5
  12. package/dist/executer/archive-task-entry.js +4 -2
  13. package/dist/executer/deadline.js +17 -3
  14. package/dist/executer/deferred-restore.js +36 -0
  15. package/dist/executer/docker-service.js +30 -8
  16. package/dist/executer/docker-task.js +21 -1
  17. package/dist/executer/execute-work-service.js +2 -0
  18. package/dist/executer/execute-work-task.js +11 -4
  19. package/dist/executer/execution-steps.js +25 -4
  20. package/dist/executer/reset-work-tree.js +4 -0
  21. package/dist/executer/scheduler/enqueue-next.js +57 -6
  22. package/dist/executer/scheduler/state-key.js +42 -5
  23. package/dist/executer/watch-loop.js +6 -3
  24. package/dist/harness/connect.js +20 -6
  25. package/dist/harness/mcp-command.js +22 -6
  26. package/dist/harness/mcp.js +63 -114
  27. package/dist/harness/opencode.js +158 -50
  28. package/dist/harness/pi.js +29 -0
  29. package/dist/harness/sandbox-client.js +194 -23
  30. package/dist/harness/service-tools.js +141 -0
  31. package/dist/harness/task-tools.js +221 -0
  32. package/dist/kubernetes/apply.js +76 -17
  33. package/dist/kubernetes/await-running-state.js +172 -13
  34. package/dist/kubernetes/cluster-state.js +245 -0
  35. package/dist/kubernetes/ensure-http-route.js +1 -1
  36. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +138 -18
  37. package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
  38. package/dist/kubernetes/ensure-persistent-data.js +212 -10
  39. package/dist/kubernetes/follow-pod-logs.js +182 -0
  40. package/dist/kubernetes/get-service-ip.js +2 -2
  41. package/dist/kubernetes/job-placement.js +36 -12
  42. package/dist/kubernetes/job-secrets.js +9 -6
  43. package/dist/kubernetes/kept-claims.js +22 -10
  44. package/dist/kubernetes/kubernetes-instance.js +1 -0
  45. package/dist/kubernetes/network-policy.js +16 -8
  46. package/dist/kubernetes/remove-persistent-data.js +3 -1
  47. package/dist/kubernetes/remove-stale-items.js +46 -0
  48. package/dist/kubernetes/resources.js +25 -3
  49. package/dist/kubernetes/restore-kubernetes-data.js +9 -5
  50. package/dist/kubernetes/sandbox-runner.js +628 -0
  51. package/dist/kubernetes/secrets.js +7 -11
  52. package/dist/kubernetes/service-rollouts.js +74 -0
  53. package/dist/kubernetes/store-kubernetes-data.js +5 -2
  54. package/dist/kubernetes/volumes.js +96 -11
  55. package/dist/main.js +2 -0
  56. package/dist/optimizer/calculate-checksum.js +3 -2
  57. package/dist/optimizer/check-key.js +94 -0
  58. package/dist/optimizer/check-keys.js +64 -0
  59. package/dist/optimizer/get-work-cache-stats.js +17 -3
  60. package/dist/optimizer/work-task-cache-description.js +49 -1
  61. package/dist/planner/service-definition.js +9 -4
  62. package/dist/planner/utils/append-work-dependencies.js +5 -3
  63. package/dist/planner/utils/append-work-service.js +18 -1
  64. package/dist/planner/utils/append-work-task.js +13 -3
  65. package/dist/planner/utils/get-work-runtime.js +6 -1
  66. package/dist/planner/utils/normalize-path.js +19 -1
  67. package/dist/planner/utils/parse-work-mount.js +7 -1
  68. package/dist/planner/utils/parse-work-port.js +4 -1
  69. package/dist/planner/validate.js +22 -0
  70. package/dist/planner/work-network.js +3 -1
  71. package/dist/planner/work-runtime-aggregate.js +32 -0
  72. package/dist/planner/work-runtime-kubernetes.js +153 -26
  73. package/dist/planner/work-task.js +6 -1
  74. package/dist/program.js +27 -9
  75. package/dist/remote/device-login.js +7 -2
  76. package/dist/remote/host-config.js +69 -3
  77. package/dist/remote/remote-commands.js +33 -7
  78. package/dist/remote/remote-status.js +112 -0
  79. package/dist/remote/remote-work-commands.js +562 -45
  80. package/dist/remote/server-client.js +15 -2
  81. package/dist/remote/token-source.js +64 -26
  82. package/dist/run-program.js +9 -0
  83. package/dist/schema/build-file-base-task-schema.js +3 -3
  84. package/dist/schema/build-file-container-service-schema.js +10 -0
  85. package/dist/schema/build-file-container-task-schema.js +16 -1
  86. package/dist/schema/build-file-include-schema.js +18 -4
  87. package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
  88. package/dist/schema/build-file-task-schema.js +5 -1
  89. package/dist/schema/capabilities-schema.js +56 -0
  90. package/dist/schema/reference-parser.js +119 -11
  91. package/dist/schema/removed-properties.js +20 -0
  92. package/dist/schema/schema-parser.js +94 -41
  93. package/dist/schema/work-scope-parser.js +33 -10
  94. package/dist/secrets/command-secret-provider.js +3 -1
  95. package/dist/secrets/github-app-token-command.js +3 -1
  96. package/dist/secrets/secret-catalog.js +43 -19
  97. package/dist/server/accounts-api.js +4 -1
  98. package/dist/server/approvals-api.js +1 -1
  99. package/dist/server/capacity-format.js +36 -0
  100. package/dist/server/capacity.js +300 -0
  101. package/dist/server/checks-api.js +83 -0
  102. package/dist/server/checks.js +116 -0
  103. package/dist/server/dashboard.js +196 -0
  104. package/dist/server/definitions-api.js +66 -3
  105. package/dist/server/endpoints.js +794 -0
  106. package/dist/server/executor.js +164 -9
  107. package/dist/server/kubernetes-backend.js +478 -70
  108. package/dist/server/kubernetes-sandbox-storage.js +46 -2
  109. package/dist/server/overview.js +271 -0
  110. package/dist/server/plan.js +170 -121
  111. package/dist/server/policy.js +34 -2
  112. package/dist/server/proposals-api.js +17 -8
  113. package/dist/server/repo-access.js +5 -0
  114. package/dist/server/run-logs.js +4 -0
  115. package/dist/server/run-plan.js +38 -4
  116. package/dist/server/run-request.js +58 -3
  117. package/dist/server/run-worker.js +262 -13
  118. package/dist/server/runs.js +969 -36
  119. package/dist/server/sandbox-pod.js +28 -0
  120. package/dist/server/sandbox-storage.js +23 -0
  121. package/dist/server/sandboxes.js +8 -0
  122. package/dist/server/server-config.js +246 -10
  123. package/dist/server/server.js +429 -32
  124. package/dist/server/service-runs.js +82 -0
  125. package/dist/server/timings.js +132 -0
  126. package/dist/server/wait-reason.js +21 -0
  127. package/dist/utils/tar.js +28 -1
  128. package/dist/utils/terminal-prompt.js +53 -0
  129. package/package.json +8 -2
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleDashboard = exports.HEADERS = exports.DECIDE_WITHIN_MS = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const promises_1 = require("fs/promises");
6
+ const path_1 = require("path");
7
+ const http_1 = require("./http");
8
+ const policy_1 = require("./policy");
9
+ const overview_1 = require("./overview");
10
+ // Approving and publishing take a sign-in of the last minutes: the groups of a session are
11
+ // those of its sign-in, and somebody taken out of a group must not decide for hours.
12
+ exports.DECIDE_WITHIN_MS = 15 * 60000;
13
+ const RUN = 'run_[A-Za-z0-9_-]{16}';
14
+ // a hash of a build file version, checked by the API
15
+ const HASH = '[A-Za-z0-9:_-]{1,200}';
16
+ const path = (pattern) => new RegExp(`^/v1/${pattern}$`);
17
+ // what the pages read
18
+ const READS = [
19
+ 'me',
20
+ 'overview',
21
+ 'capacity',
22
+ 'tasks',
23
+ 'runs',
24
+ `runs/${RUN}`,
25
+ `runs/${RUN}/logs`,
26
+ 'approvals',
27
+ `approvals/${RUN}`,
28
+ 'proposals',
29
+ `proposals/${HASH}`,
30
+ `proposals/${HASH}/files`,
31
+ ].map(path);
32
+ // what the buttons do
33
+ const ACTIONS = [`runs/${RUN}/(cancel|extend)`].map(path);
34
+ // what needs a recent sign-in
35
+ const DECISIONS = [`approvals/${RUN}/(approve|reject)`, `proposals/${HASH}/(approve|reject)`].map(path);
36
+ exports.HEADERS = {
37
+ 'cache-control': 'no-store',
38
+ 'x-robots-tag': 'noindex',
39
+ // the app is a script and a style sheet of its own host, and talks to its host only
40
+ 'content-security-policy': "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; " +
41
+ "font-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
42
+ 'x-frame-options': 'DENY',
43
+ 'referrer-policy': 'no-referrer',
44
+ 'x-content-type-options': 'nosniff',
45
+ };
46
+ const TYPES = {
47
+ '.js': 'text/javascript; charset=utf-8',
48
+ '.css': 'text/css; charset=utf-8',
49
+ '.svg': 'image/svg+xml',
50
+ '.woff2': 'font/woff2',
51
+ '.png': 'image/png',
52
+ };
53
+ async function handleDashboard(context, principal, req, res, url) {
54
+ const now = context.now ?? Date.now;
55
+ const grant = (0, policy_1.getGrant)(context.config.entitlements, principal);
56
+ const viewer = { principal, grant, activity: context.config.runs.activity === 'everyone' };
57
+ const pathname = url.pathname;
58
+ for (const [name, value] of Object.entries(exports.HEADERS)) {
59
+ res.setHeader(name, value);
60
+ }
61
+ if (pathname === '/api' || pathname.startsWith('/api/')) {
62
+ if (grant.matched.length === 0) {
63
+ return (0, http_1.send)(res, 403, { error: 'you are not entitled to anything on this server' });
64
+ }
65
+ return api(context, principal, viewer, now(), req, res, url);
66
+ }
67
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
68
+ return (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, HEAD' });
69
+ }
70
+ return asset(context, req, res, pathname);
71
+ }
72
+ exports.handleDashboard = handleDashboard;
73
+ async function api(context, principal, viewer, now, req, res, url) {
74
+ const { grant } = viewer;
75
+ const csrf = context.sign(`dashboard\0csrf\0${principal.iss}\0${principal.sub}\0${context.session ?? 'token'}`);
76
+ const method = req.method ?? 'GET';
77
+ if (url.pathname === '/api/session') {
78
+ if (method !== 'GET') {
79
+ return (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
80
+ }
81
+ const session = {
82
+ iss: principal.iss,
83
+ sub: principal.sub,
84
+ observe: grant.observe,
85
+ seesOthers: grant.observe || (0, overview_1.seesActivity)(viewer),
86
+ publishes: (0, policy_1.mayPublish)(grant),
87
+ csrf,
88
+ ...(context.signedInAt !== undefined
89
+ ? { decideUntil: new Date(context.signedInAt + exports.DECIDE_WITHIN_MS).toISOString() }
90
+ : {}),
91
+ decideWithinMinutes: exports.DECIDE_WITHIN_MS / 60000,
92
+ };
93
+ return (0, http_1.send)(res, 200, session);
94
+ }
95
+ const download = new RegExp(`^/api/runs/(${RUN})/log\\.txt$`).exec(url.pathname);
96
+ if (download) {
97
+ if (method !== 'GET') {
98
+ return (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
99
+ }
100
+ return logText(context, viewer, res, download[1], url.searchParams.get('task') ?? undefined);
101
+ }
102
+ const target = url.pathname.slice('/api'.length);
103
+ const read = READS.some((pattern) => pattern.test(target));
104
+ const action = ACTIONS.some((pattern) => pattern.test(target));
105
+ const decision = DECISIONS.some((pattern) => pattern.test(target));
106
+ if (!read && !action && !decision) {
107
+ return (0, http_1.send)(res, 404, { error: 'not found' });
108
+ }
109
+ if (method === 'GET' || method === 'HEAD') {
110
+ if (!read) {
111
+ return (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
112
+ }
113
+ }
114
+ else if (method === 'POST' && (action || decision)) {
115
+ // a page of another site can not set the header, nor read the token
116
+ const given = Buffer.from(String(req.headers['x-hammerkit-csrf'] ?? ''));
117
+ const expected = Buffer.from(csrf);
118
+ if (given.length !== expected.length || !(0, crypto_1.timingSafeEqual)(given, expected)) {
119
+ return (0, http_1.send)(res, 403, { error: 'the page is out of date, open it again' });
120
+ }
121
+ // an API token is new; a sign-in in the browser has to be of the last minutes to decide
122
+ if (decision && context.signedInAt !== undefined && now - context.signedInAt > exports.DECIDE_WITHIN_MS) {
123
+ return (0, http_1.send)(res, 403, {
124
+ error: `approving and rejecting take a sign-in of the last ${exports.DECIDE_WITHIN_MS / 60000} minutes`,
125
+ signInAgain: true,
126
+ });
127
+ }
128
+ context.log(`dashboard ${method} ${target} iss=${principal.iss} sub=${principal.sub}`);
129
+ }
130
+ else {
131
+ return (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: read ? 'GET' : 'POST' });
132
+ }
133
+ const forwarded = new URL(`${target}${url.search}`, 'http://localhost');
134
+ return context.api(principal, req, res, forwarded);
135
+ }
136
+ // The log as a text file: what the tasks printed, as they printed it.
137
+ function logText(context, viewer, res, id, task) {
138
+ const run = context.registry.peek(id);
139
+ // the log is for the owner of the run, who decides about it and observe
140
+ const logs = run && (0, overview_1.maySee)(viewer, run) && (0, overview_1.mayRead)(viewer, run) ? context.registry.logsOf(id) : undefined;
141
+ if (!run || !logs) {
142
+ return (0, http_1.send)(res, 404, { error: 'not found' });
143
+ }
144
+ const names = logs.index.names();
145
+ const chosen = task !== undefined && names.includes(task) ? task : undefined;
146
+ const lines = [];
147
+ const selected = chosen ? logs.index.lines(chosen) : undefined;
148
+ const total = selected ? selected.length : logs.index.size;
149
+ for (let position = 0; position < total; position++) {
150
+ const line = logs.index.text(logs.events, selected ? selected[position] : position);
151
+ lines.push(chosen ? line.text : `[${line.task}] ${line.text}`);
152
+ }
153
+ if (logs.truncated) {
154
+ lines.push('[hammerkit] the log was cut off at runs.maxLogBytes');
155
+ }
156
+ res.writeHead(200, {
157
+ 'content-type': 'text/plain; charset=utf-8',
158
+ 'content-disposition': `attachment; filename="${run.id}${chosen ? `-${chosen.replace(/[^A-Za-z0-9._-]/g, '_')}` : ''}.log"`,
159
+ });
160
+ res.end(lines.join('\n') + (lines.length > 0 ? '\n' : ''));
161
+ }
162
+ // The files of the app: its scripts and styles by their hashed names, and index.html for
163
+ // every page, which the app draws.
164
+ async function asset(context, req, res, pathname) {
165
+ const root = context.assets ?? (0, path_1.join)(__dirname, '..', 'dashboard');
166
+ const file = /^\/assets\/[A-Za-z0-9_-][A-Za-z0-9._-]{0,200}$/.test(pathname)
167
+ ? pathname.slice(1)
168
+ : pathname === '/favicon.svg'
169
+ ? 'favicon.svg'
170
+ : pathname.startsWith('/assets/') || pathname.startsWith('/.')
171
+ ? undefined
172
+ : 'index.html';
173
+ if (!file) {
174
+ return (0, http_1.send)(res, 404, { error: 'not found' });
175
+ }
176
+ let content;
177
+ try {
178
+ content = await (0, promises_1.readFile)((0, path_1.join)(root, file));
179
+ }
180
+ catch {
181
+ if (file === 'index.html') {
182
+ res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
183
+ res.end('The dashboard of this server was not built (npm run build:dashboard).\n');
184
+ return;
185
+ }
186
+ return (0, http_1.send)(res, 404, { error: 'not found' });
187
+ }
188
+ const extension = /\.[a-z0-9]+$/.exec(file)?.[0] ?? '';
189
+ res.writeHead(200, {
190
+ 'content-type': file === 'index.html' ? 'text/html; charset=utf-8' : TYPES[extension] ?? 'application/octet-stream',
191
+ // the names of the scripts and styles change with what they hold
192
+ ...(file.startsWith('assets/') ? { 'cache-control': 'public, max-age=31536000, immutable' } : {}),
193
+ });
194
+ res.end(req.method === 'HEAD' ? undefined : content);
195
+ }
196
+ //# sourceMappingURL=dashboard.js.map
@@ -5,6 +5,7 @@ const zod_1 = require("zod");
5
5
  const bundle_1 = require("../remote/bundle");
6
6
  const definitions_1 = require("./definitions");
7
7
  const http_1 = require("./http");
8
+ const run_plan_1 = require("./run-plan");
8
9
  const oauth_1 = require("./oauth");
9
10
  const policy_1 = require("./policy");
10
11
  const proposals_1 = require("./proposals");
@@ -19,12 +20,25 @@ const MAX_PUBLISH_BYTES = bundle_1.MAX_BUNDLE_BYTES * 2;
19
20
  * `annotations`, which are not part of the hash
20
21
  * GET /v1/definitions the versions, newest first; `?<key>=<value>`
21
22
  * keeps the ones with those annotations
22
- * GET /v1/definitions/<hash> one version
23
+ * GET /v1/definitions/<hash> one version, with `arguments`: per task null when
24
+ * a run of it may pass arguments, else why not
23
25
  * GET /v1/definitions/<hash>/files its files
26
+ * POST /v1/definitions/plan what publishing a bundle would do: plans it with this
27
+ * server's policy and stores nothing. For anybody with
28
+ * a `builds` level, `run` too
24
29
  * POST /v1/definitions/<hash>/yank pull it back (body: optional {reason})
25
30
  * POST /v1/definitions/<hash>/unyank let it go on
26
31
  */
27
32
  async function handleDefinitions(config, store, proposals, log, principal, req, res, url) {
33
+ if (url.pathname === '/v1/definitions/plan') {
34
+ if (req.method !== 'POST') {
35
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
36
+ }
37
+ else {
38
+ await plan(config, store, log, principal, req, res);
39
+ }
40
+ return true;
41
+ }
28
42
  const match = /^\/v1\/definitions(?:\/([^/]+)(\/files|\/yank|\/unyank)?)?$/.exec(url.pathname);
29
43
  if (!match) {
30
44
  return false;
@@ -72,7 +86,7 @@ async function handleDefinitions(config, store, proposals, log, principal, req,
72
86
  bundle ? (0, http_1.send)(res, 200, bundle) : (0, http_1.send)(res, 404, { error: 'not found' });
73
87
  }
74
88
  else {
75
- (0, http_1.send)(res, 200, found);
89
+ (0, http_1.send)(res, 200, found.plan ? { ...found, arguments: (0, run_plan_1.describeArguments)(found.plan, config.runs) } : found);
76
90
  }
77
91
  return true;
78
92
  }
@@ -122,6 +136,55 @@ async function yank(config, store, log, principal, hash, undo, req, res) {
122
136
  log(`${undo ? 'unyank' : 'yank'} ${hash} iss=${principal.iss} sub=${principal.sub}${body.data.reason ? ` reason=${(0, oauth_1.quote)(body.data.reason)}` : ''}`);
123
137
  (0, http_1.send)(res, 200, changed);
124
138
  }
139
+ // Answers what POST /v1/definitions would: the version with its plan, and whether the
140
+ // caller would publish or propose it. A developer checks a build file against the policy
141
+ // before the account that publishes does. Nothing is stored, nothing is logged but the call.
142
+ async function plan(config, store, log, principal, req, res) {
143
+ const grant = (0, policy_1.getGrant)(config.entitlements, principal);
144
+ if (grant.builds === null) {
145
+ (0, http_1.send)(res, 403, { error: 'you may not check build files, it needs builds: run, propose or publish' });
146
+ return;
147
+ }
148
+ const parsed = bundle_1.bundleSchema.safeParse(await (0, http_1.readJson)(req, MAX_PUBLISH_BYTES));
149
+ if (!parsed.success) {
150
+ const issue = parsed.error.issues[0];
151
+ (0, http_1.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
152
+ return;
153
+ }
154
+ try {
155
+ // like a proposal: a caller who may not publish does not make the server fetch
156
+ const { version, existing } = await store.prepare(parsed.data, principal, {
157
+ noRemote: (0, policy_1.mayPublish)(grant) ? undefined : 'check',
158
+ });
159
+ const missing = (version.accounts ?? []).filter((account) => !(0, policy_1.canUse)(grant, account));
160
+ const outcome = existing
161
+ ? 'already published'
162
+ : (0, policy_1.mayPublish)(grant) && missing.length === 0
163
+ ? 'published'
164
+ : (0, policy_1.mayPublish)(grant) || (0, policy_1.mayPropose)(grant)
165
+ ? 'proposed'
166
+ : null;
167
+ log(`plan ${version.hash} iss=${principal.iss} sub=${principal.sub} tasks=${version.tasks.length}`);
168
+ (0, http_1.send)(res, 200, {
169
+ hash: version.hash,
170
+ tasks: version.tasks,
171
+ services: version.services,
172
+ plan: version.plan,
173
+ accounts: version.accounts,
174
+ outcome,
175
+ // a yanked version is not checked again: it is the one that was published
176
+ ...(existing?.yanked ? { yanked: true } : {}),
177
+ ...(missing.length > 0 ? { missing } : {}),
178
+ });
179
+ }
180
+ catch (e) {
181
+ if (e instanceof definitions_1.DefinitionError) {
182
+ (0, http_1.send)(res, 400, { error: `the build file does not load: ${e.message}` });
183
+ return;
184
+ }
185
+ throw e;
186
+ }
187
+ }
125
188
  async function publish(config, store, proposals, log, principal, req, res) {
126
189
  const grant = (0, policy_1.getGrant)(config.entitlements, principal);
127
190
  if (!(0, policy_1.mayPublish)(grant) && !(0, policy_1.mayPropose)(grant)) {
@@ -149,7 +212,7 @@ async function publish(config, store, proposals, log, principal, req, res) {
149
212
  // a version can't run before it is approved, but it has to load, as it would
150
213
  // have when published, so the approver is not asked about a broken one
151
214
  const { version, existing } = await store.prepare(parsed.data, principal, {
152
- noRemote: !(0, policy_1.mayPublish)(grant),
215
+ noRemote: (0, policy_1.mayPublish)(grant) ? undefined : 'proposal',
153
216
  annotations,
154
217
  });
155
218
  if (existing) {