hammerkit 1.26.0 → 1.27.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 (117) hide show
  1. package/build.schema.json +53 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/explain.js +13 -11
  4. package/dist/cli.js +3 -0
  5. package/dist/docker/item-network.js +471 -0
  6. package/dist/docker/pull.js +4 -2
  7. package/dist/docker/remove-orphaned-containers.js +13 -6
  8. package/dist/environment/replace-env-variables.js +8 -6
  9. package/dist/executer/await-completed-dependencies.js +2 -1
  10. package/dist/executer/check-readiness.js +2 -2
  11. package/dist/executer/docker-service.js +24 -13
  12. package/dist/executer/docker-task.js +18 -29
  13. package/dist/executer/event-cache.js +4 -0
  14. package/dist/executer/execute-command.js +18 -5
  15. package/dist/executer/execute-docker.js +4 -1
  16. package/dist/executer/execution-steps.js +33 -7
  17. package/dist/executer/get-docker-executor.js +14 -7
  18. package/dist/executer/scheduler/enqueue-next.js +17 -3
  19. package/dist/executer/watch-loop.js +2 -2
  20. package/dist/executer/watch-state-key.js +1 -1
  21. package/dist/file/within-root.js +42 -0
  22. package/dist/harness/connect.js +99 -0
  23. package/dist/harness/format.js +73 -0
  24. package/dist/harness/mcp-command.js +59 -0
  25. package/dist/harness/mcp.js +432 -0
  26. package/dist/harness/opencode.js +492 -0
  27. package/dist/harness/pi.js +217 -0
  28. package/dist/harness/sandbox-client.js +373 -0
  29. package/dist/index.js +33 -36
  30. package/dist/kubernetes/apply.js +31 -1
  31. package/dist/kubernetes/await-running-state.js +33 -1
  32. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +43 -21
  33. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
  34. package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
  35. package/dist/kubernetes/ensure-persistent-data.js +48 -7
  36. package/dist/kubernetes/get-service-ip.js +15 -1
  37. package/dist/kubernetes/job-placement.js +99 -0
  38. package/dist/kubernetes/job-secrets.js +306 -0
  39. package/dist/kubernetes/kept-claims.js +191 -0
  40. package/dist/kubernetes/kubernetes-instance.js +10 -2
  41. package/dist/kubernetes/network-gate.js +71 -0
  42. package/dist/kubernetes/network-policy.js +526 -0
  43. package/dist/kubernetes/resources.js +13 -2
  44. package/dist/kubernetes/restore-kubernetes-data.js +19 -4
  45. package/dist/kubernetes/secrets.js +2 -0
  46. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  47. package/dist/kubernetes/volumes.js +100 -11
  48. package/dist/main.js +41 -0
  49. package/dist/network/egress-proxy.js +368 -0
  50. package/dist/network/port-relay.js +35 -0
  51. package/dist/optimizer/get-work-cache-stats.js +16 -0
  52. package/dist/optimizer/work-task-cache-description.js +3 -2
  53. package/dist/planner/service-definition.js +5 -0
  54. package/dist/planner/utils/append-work-environment.js +2 -0
  55. package/dist/planner/utils/append-work-service.js +4 -0
  56. package/dist/planner/utils/append-work-task.js +17 -1
  57. package/dist/planner/utils/assert-item-paths.js +50 -0
  58. package/dist/planner/utils/hosted-volumes.js +35 -0
  59. package/dist/planner/work-network.js +193 -0
  60. package/dist/planner/work-runtime-docker.js +41 -0
  61. package/dist/planner/work-runtime-kubernetes.js +335 -102
  62. package/dist/program.js +21 -0
  63. package/dist/remote/annotations.js +103 -0
  64. package/dist/remote/bundle-text.js +24 -0
  65. package/dist/remote/bundle.js +9 -1
  66. package/dist/remote/remote-commands.js +4 -1
  67. package/dist/remote/remote-work-commands.js +182 -69
  68. package/dist/remote/server-client.js +18 -5
  69. package/dist/remote/source-manifest.js +15 -2
  70. package/dist/remote/token-source.js +67 -0
  71. package/dist/schema/build-file-base-task-schema.js +3 -0
  72. package/dist/schema/build-file-container-service-schema.js +6 -0
  73. package/dist/schema/build-file-container-task-schema.js +3 -0
  74. package/dist/schema/build-file-environment-schema.js +9 -0
  75. package/dist/schema/network-schema.js +78 -0
  76. package/dist/schema/reference-parser.js +1 -0
  77. package/dist/schema/schema-parser.js +16 -3
  78. package/dist/schema/work-scope-parser.js +5 -0
  79. package/dist/secrets/command-secret-provider.js +73 -10
  80. package/dist/secrets/github-app-token-command.js +23 -0
  81. package/dist/secrets/github-app-token.js +191 -0
  82. package/dist/server/accounts-api.js +9 -2
  83. package/dist/server/definitions-api.js +69 -47
  84. package/dist/server/definitions.js +76 -51
  85. package/dist/server/executor.js +156 -41
  86. package/dist/server/git-checkout.js +72 -8
  87. package/dist/server/kubernetes-backend.js +554 -0
  88. package/dist/server/kubernetes-sandbox-storage.js +996 -0
  89. package/dist/server/oauth.js +1 -0
  90. package/dist/server/plan.js +51 -1
  91. package/dist/server/policy.js +104 -15
  92. package/dist/server/proposals-api.js +24 -40
  93. package/dist/server/proposals.js +12 -25
  94. package/dist/server/repo-access.js +37 -6
  95. package/dist/server/run-logs.js +217 -0
  96. package/dist/server/run-plan.js +51 -2
  97. package/dist/server/run-request.js +23 -10
  98. package/dist/server/run-store.js +5 -0
  99. package/dist/server/run-worker-command.js +13 -1
  100. package/dist/server/run-worker.js +121 -24
  101. package/dist/server/runs.js +184 -10
  102. package/dist/server/sandbox-docker.js +62 -0
  103. package/dist/server/sandbox-files-api.js +269 -0
  104. package/dist/server/sandbox-files.js +1054 -0
  105. package/dist/server/sandbox-patch.js +481 -0
  106. package/dist/server/sandbox-pod-command.js +15 -0
  107. package/dist/server/sandbox-pod.js +146 -0
  108. package/dist/server/sandbox-storage.js +462 -0
  109. package/dist/server/sandboxes-api.js +210 -0
  110. package/dist/server/sandboxes.js +519 -0
  111. package/dist/server/server-config.js +412 -22
  112. package/dist/server/server.js +204 -39
  113. package/dist/utils/run-id.js +12 -0
  114. package/harness/opencode.ts +11 -0
  115. package/harness/pi.ts +7 -0
  116. package/package.json +8 -2
  117. package/dist/remote/text-diff.js +0 -89
@@ -8,45 +8,43 @@ const http_1 = require("./http");
8
8
  const oauth_1 = require("./oauth");
9
9
  const policy_1 = require("./policy");
10
10
  const proposals_1 = require("./proposals");
11
- const server_config_1 = require("./server-config");
12
11
  // A bundle is JSON text with escapes, so the body may be somewhat larger than the files.
13
12
  const MAX_PUBLISH_BYTES = bundle_1.MAX_BUNDLE_BYTES * 2;
14
13
  /**
15
- * The published build files:
14
+ * The published build files, each version named by its hash:
16
15
  *
17
- * PUT /v1/definitions/<name> publish a bundle, or propose it when the
18
- * caller may only propose (answers 202)
19
- * GET /v1/definitions the names, with their latest version
20
- * GET /v1/definitions/<name> the versions of one
21
- * GET /v1/definitions/<name>/<hash|latest> one version
22
- * GET /v1/definitions/<name>/<hash|latest>/files the files of one version
23
- * POST /v1/definitions/<name>/<hash>/yank pull a version back (body: optional {reason})
24
- * POST /v1/definitions/<name>/<hash>/unyank let it go on
16
+ * POST /v1/definitions publish a bundle, or propose it when the caller
17
+ * may only propose (answers 202); answers its hash.
18
+ * Beside entry and files the body may carry
19
+ * `annotations`, which are not part of the hash
20
+ * GET /v1/definitions the versions, newest first; `?<key>=<value>`
21
+ * keeps the ones with those annotations
22
+ * GET /v1/definitions/<hash> one version
23
+ * GET /v1/definitions/<hash>/files its files
24
+ * POST /v1/definitions/<hash>/yank pull it back (body: optional {reason})
25
+ * POST /v1/definitions/<hash>/unyank let it go on
25
26
  */
26
27
  async function handleDefinitions(config, store, proposals, log, principal, req, res, url) {
27
- const match = /^\/v1\/definitions(?:\/([^/]+)(?:\/([^/]+)(\/files|\/yank|\/unyank)?)?)?$/.exec(url.pathname);
28
+ const match = /^\/v1\/definitions(?:\/([^/]+)(\/files|\/yank|\/unyank)?)?$/.exec(url.pathname);
28
29
  if (!match) {
29
30
  return false;
30
31
  }
31
- const [, name, version, suffix] = match;
32
- const files = suffix === '/files';
32
+ const [, hash, suffix] = match;
33
33
  const grant = (0, policy_1.getGrant)(config.entitlements, principal);
34
- if (name !== undefined && !server_config_1.definitionName.safeParse(name).success) {
34
+ if (hash !== undefined && !bundle_1.HASH_PATTERN.test(hash)) {
35
35
  (0, http_1.send)(res, 404, { error: 'not found' });
36
36
  return true;
37
37
  }
38
- if (name !== undefined && version === undefined && req.method === 'PUT') {
39
- await publish(config, store, proposals, log, principal, name, req, res);
38
+ if (hash === undefined && req.method === 'POST') {
39
+ await publish(config, store, proposals, log, principal, req, res);
40
40
  return true;
41
41
  }
42
42
  if ((suffix === '/yank' || suffix === '/unyank') && req.method === 'POST') {
43
- await yank(config, store, log, principal, name, version, suffix === '/unyank', req, res);
43
+ await yank(config, store, log, principal, hash, suffix === '/unyank', req, res);
44
44
  return true;
45
45
  }
46
46
  if (req.method !== 'GET') {
47
- (0, http_1.send)(res, 405, { error: 'method not allowed' }, {
48
- allow: suffix === '/yank' || suffix === '/unyank' ? 'POST' : name !== undefined && !version ? 'GET, PUT' : 'GET',
49
- });
47
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: suffix === '/yank' || suffix === '/unyank' ? 'POST' : hash === undefined ? 'GET, POST' : 'GET' });
50
48
  return true;
51
49
  }
52
50
  // what the caller may not see does not exist for them
@@ -54,25 +52,22 @@ async function handleDefinitions(config, store, proposals, log, principal, req,
54
52
  (0, http_1.send)(res, 404, { error: 'not found' });
55
53
  return true;
56
54
  }
57
- if (name === undefined) {
55
+ if (hash === undefined) {
56
+ const wanted = [...url.searchParams.entries()];
58
57
  (0, http_1.send)(res, 200, {
59
- definitions: store.names().map((n) => ({ name: n, latest: store.find(n, 'latest')?.hash })),
58
+ definitions: store
59
+ .list()
60
+ .filter((version) => wanted.every(([key, value]) => version.annotations?.[key] === value))
61
+ .map(toSummary),
60
62
  });
61
63
  return true;
62
64
  }
63
- if (version === undefined) {
64
- const versions = store.list(name);
65
- versions.length > 0
66
- ? (0, http_1.send)(res, 200, { name, latest: versions[0].hash, versions })
67
- : (0, http_1.send)(res, 404, { error: 'not found' });
68
- return true;
69
- }
70
- const found = version === 'latest' || bundle_1.HASH_PATTERN.test(version) ? store.find(name, version) : undefined;
65
+ const found = store.find(hash);
71
66
  if (!found) {
72
67
  (0, http_1.send)(res, 404, { error: 'not found' });
73
68
  return true;
74
69
  }
75
- if (files) {
70
+ if (suffix === '/files') {
76
71
  const bundle = await store.files(found);
77
72
  bundle ? (0, http_1.send)(res, 200, bundle) : (0, http_1.send)(res, 404, { error: 'not found' });
78
73
  }
@@ -82,22 +77,38 @@ async function handleDefinitions(config, store, proposals, log, principal, req,
82
77
  return true;
83
78
  }
84
79
  exports.handleDefinitions = handleDefinitions;
80
+ // What the list shows of a version: who published it and when, and what it runs as.
81
+ function toSummary(version) {
82
+ const { hash, entry, publishedAt, publishedBy, approvedBy, annotations, tasks, services, accounts, yanked } = version;
83
+ return {
84
+ hash,
85
+ entry,
86
+ publishedAt,
87
+ publishedBy,
88
+ approvedBy,
89
+ annotations: annotations ?? {},
90
+ tasks,
91
+ services,
92
+ accounts: accounts ?? [],
93
+ yanked,
94
+ };
95
+ }
85
96
  const yankSchema = zod_1.z.object({ reason: zod_1.z.string().max(500).optional() }).strict();
86
97
  // Yanking is allowed to who publishes the build file or uses an account the version
87
98
  // uses; letting a version go on again is the publisher's call.
88
- async function yank(config, store, log, principal, name, hash, undo, req, res) {
99
+ async function yank(config, store, log, principal, hash, undo, req, res) {
89
100
  const grant = (0, policy_1.getGrant)(config.entitlements, principal);
90
- const version = bundle_1.HASH_PATTERN.test(hash) ? store.find(name, hash) : undefined;
101
+ const version = store.find(hash);
91
102
  if (!version || !(0, policy_1.maySeeDefinitions)(grant)) {
92
103
  (0, http_1.send)(res, 404, { error: 'not found' });
93
104
  return;
94
105
  }
95
106
  if (undo ? !(0, policy_1.mayPublish)(grant) : !(0, policy_1.mayYank)(grant, version)) {
96
- log(`refuse ${undo ? 'unyank' : 'yank'} ${name}@${hash} iss=${principal.iss} sub=${principal.sub}`);
107
+ log(`refuse ${undo ? 'unyank' : 'yank'} ${hash} iss=${principal.iss} sub=${principal.sub}`);
97
108
  (0, http_1.send)(res, 403, {
98
109
  error: undo
99
- ? 'a version is let go on by who publishes the build file'
100
- : 'a version is yanked by who publishes the build file or uses an account it uses',
110
+ ? 'a version is let go on by who publishes build files'
111
+ : 'a version is yanked by who publishes build files or uses an account it uses',
101
112
  });
102
113
  return;
103
114
  }
@@ -107,18 +118,28 @@ async function yank(config, store, log, principal, name, hash, undo, req, res) {
107
118
  (0, http_1.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
108
119
  return;
109
120
  }
110
- const changed = await store.yank(name, hash, principal, { reason: body.data.reason, undo });
111
- log(`${undo ? 'unyank' : 'yank'} ${name}@${hash} iss=${principal.iss} sub=${principal.sub}${body.data.reason ? ` reason=${(0, oauth_1.quote)(body.data.reason)}` : ''}`);
121
+ const changed = await store.yank(hash, principal, { reason: body.data.reason, undo });
122
+ log(`${undo ? 'unyank' : 'yank'} ${hash} iss=${principal.iss} sub=${principal.sub}${body.data.reason ? ` reason=${(0, oauth_1.quote)(body.data.reason)}` : ''}`);
112
123
  (0, http_1.send)(res, 200, changed);
113
124
  }
114
- async function publish(config, store, proposals, log, principal, name, req, res) {
125
+ async function publish(config, store, proposals, log, principal, req, res) {
115
126
  const grant = (0, policy_1.getGrant)(config.entitlements, principal);
116
127
  if (!(0, policy_1.mayPublish)(grant) && !(0, policy_1.mayPropose)(grant)) {
117
128
  log(`refuse publish iss=${principal.iss} sub=${principal.sub}: no builds permission`);
118
- (0, http_1.send)(res, 403, { error: `you may not publish ${name}, it needs builds: propose or publish` });
129
+ (0, http_1.send)(res, 403, { error: 'you may not publish build files, it needs builds: propose or publish' });
130
+ return;
131
+ }
132
+ const body = await (0, http_1.readJson)(req, MAX_PUBLISH_BYTES);
133
+ // the annotations ride along with the bundle, they are not part of it
134
+ const { annotations: said, ...rest } = body && typeof body === 'object' ? body : {};
135
+ const annotated = bundle_1.annotationsSchema.optional().safeParse(said);
136
+ if (!annotated.success) {
137
+ const issue = annotated.error.issues[0];
138
+ (0, http_1.send)(res, 400, { error: `${['annotations', ...issue.path].join('.')}: ${issue.message}` });
119
139
  return;
120
140
  }
121
- const parsed = bundle_1.bundleSchema.safeParse(await (0, http_1.readJson)(req, MAX_PUBLISH_BYTES));
141
+ const annotations = annotated.data;
142
+ const parsed = bundle_1.bundleSchema.safeParse(rest);
122
143
  if (!parsed.success) {
123
144
  const issue = parsed.error.issues[0];
124
145
  (0, http_1.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
@@ -127,8 +148,9 @@ async function publish(config, store, proposals, log, principal, name, req, res)
127
148
  try {
128
149
  // a version can't run before it is approved, but it has to load, as it would
129
150
  // have when published, so the approver is not asked about a broken one
130
- const { version, existing } = await store.prepare(name, parsed.data, principal, {
151
+ const { version, existing } = await store.prepare(parsed.data, principal, {
131
152
  noRemote: !(0, policy_1.mayPublish)(grant),
153
+ annotations,
132
154
  });
133
155
  if (existing) {
134
156
  (0, http_1.send)(res, 200, { ...existing, created: false });
@@ -138,18 +160,18 @@ async function publish(config, store, proposals, log, principal, name, req, res)
138
160
  // else proposes it, and somebody who does approves it (four eyes)
139
161
  const missing = (version.accounts ?? []).filter((account) => !(0, policy_1.canUse)(grant, account));
140
162
  if (!(0, policy_1.mayPublish)(grant) || missing.length > 0) {
141
- const { proposal, created } = proposals.propose(version, parsed.data, principal, missing, store.find(name, 'latest')?.hash ?? null);
142
- log(`propose ${name} ${proposal.hash} iss=${principal.iss} sub=${principal.sub} created=${created} tasks=${proposal.tasks.length} missing=[${missing.join(',')}]`);
163
+ const { proposal, created } = proposals.propose(version, parsed.data, principal, missing);
164
+ log(`propose ${proposal.hash} iss=${principal.iss} sub=${principal.sub} created=${created} tasks=${proposal.tasks.length} missing=[${missing.join(',')}]`);
143
165
  (0, http_1.send)(res, created ? 202 : 200, { ...proposal, created });
144
166
  return;
145
167
  }
146
- const { version: published, created } = await store.publish(name, parsed.data, principal);
147
- log(`publish ${name} ${published.hash} iss=${principal.iss} sub=${principal.sub} created=${created} tasks=${published.tasks.length}`);
168
+ const { version: published, created } = await store.publish(parsed.data, principal, { annotations });
169
+ log(`publish ${published.hash} iss=${principal.iss} sub=${principal.sub} created=${created} tasks=${published.tasks.length}`);
148
170
  (0, http_1.send)(res, created ? 201 : 200, { ...published, created });
149
171
  }
150
172
  catch (e) {
151
173
  if (e instanceof definitions_1.DefinitionError) {
152
- log(`refuse publish iss=${principal.iss} sub=${principal.sub}: ${name} does not load`);
174
+ log(`refuse publish iss=${principal.iss} sub=${principal.sub}: the build file does not load`);
153
175
  (0, http_1.send)(res, 400, { error: `the build file does not load: ${e.message}` });
154
176
  return;
155
177
  }
@@ -9,8 +9,10 @@ const plan_1 = require("./plan");
9
9
  var plan_2 = require("./plan");
10
10
  Object.defineProperty(exports, "DefinitionError", { enumerable: true, get: function () { return plan_2.DefinitionError; } });
11
11
  Object.defineProperty(exports, "inspectBundle", { enumerable: true, get: function () { return plan_2.inspectBundle; } });
12
- // The published build files: immutable versions, each named by its hash. They
13
- // are kept in files, so a restart doesn't forget what runs were started from.
12
+ // The published build files: immutable versions, each named by its hash and by
13
+ // nothing else, so whoever publishes can't make one look like the follower of
14
+ // another. They are kept in files, so a restart doesn't forget what runs were
15
+ // started from.
14
16
  class DefinitionStore {
15
17
  constructor(directory,
16
18
  // the environment a build file is read with: on a server, the hosted one
@@ -22,42 +24,43 @@ class DefinitionStore {
22
24
  this.now = options.now ?? Date.now;
23
25
  }
24
26
  async load() {
25
- let names = [];
27
+ let entries = [];
26
28
  try {
27
- names = await (0, promises_1.readdir)(this.directory);
29
+ entries = await (0, promises_1.readdir)(this.directory);
28
30
  }
29
31
  catch {
30
32
  return; // nothing was published yet
31
33
  }
32
- for (const name of names) {
33
- for (const file of await (0, promises_1.readdir)((0, path_1.join)(this.directory, name))) {
34
- if (!file.endsWith('.json')) {
35
- continue;
34
+ for (const entry of entries) {
35
+ const path = (0, path_1.join)(this.directory, entry);
36
+ if ((await (0, promises_1.stat)(path)).isDirectory()) {
37
+ // a name of an older server: its versions are versions like any other
38
+ for (const file of await (0, promises_1.readdir)(path)) {
39
+ await this.read((0, path_1.join)(path, file), true);
36
40
  }
37
- const stored = JSON.parse(await (0, promises_1.readFile)((0, path_1.join)(this.directory, name, file), 'utf8'));
38
- this.add(stored.version);
41
+ }
42
+ else {
43
+ await this.read(path, false);
39
44
  }
40
45
  }
41
46
  }
42
- names() {
43
- return [...this.versions.keys()].sort();
44
- }
45
47
  // newest first
46
- list(name) {
47
- return this.has(name) ? [...this.versions.get(name)] : [];
48
+ list() {
49
+ return [...this.versions.values()]
50
+ .map((stored) => stored.version)
51
+ .sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
48
52
  }
49
- // a hash, or `latest` for the version published last that is not yanked
50
- find(name, hash) {
51
- const versions = this.list(name);
52
- return hash === 'latest' ? versions.find((v) => !v.yanked) : versions.find((v) => v.hash === hash);
53
+ find(hash) {
54
+ return this.versions.get(hash)?.version;
53
55
  }
54
56
  /**
55
57
  * Pulls a version back (or, with `undo`, lets it go on). The version is rewritten in
56
58
  * place, its files stay: a run that was started from it has them.
57
59
  */
58
- async yank(name, hash, by, options = {}) {
59
- const version = this.versions.get(name)?.find((v) => v.hash === hash);
60
- if (!version || Boolean(version.yanked) === !options.undo) {
60
+ async yank(hash, by, options = {}) {
61
+ const stored = this.versions.get(hash);
62
+ const version = stored?.version;
63
+ if (!stored || !version || Boolean(version.yanked) === !options.undo) {
61
64
  return version;
62
65
  }
63
66
  const bundle = await this.files(version);
@@ -75,18 +78,21 @@ class DefinitionStore {
75
78
  ...(options.reason ? { reason: options.reason } : {}),
76
79
  };
77
80
  }
78
- const file = this.file(name, hash);
79
- const temporary = `${file}.${(0, crypto_1.randomBytes)(8).toString('hex')}.tmp`;
80
- await (0, promises_1.writeFile)(temporary, JSON.stringify({ version: next, bundle }), { flag: 'wx' });
81
- await (0, promises_1.rename)(temporary, file);
81
+ // a version of an older server is written as `<hex>.json`, which counts over the
82
+ // copies it may have under several names
83
+ const file = this.fileOf(hash);
84
+ await this.write(file, next, bundle);
85
+ stored.file = file;
86
+ stored.legacy = false;
82
87
  // the same object, so whoever holds the version sees it
83
88
  Object.keys(version).forEach((key) => delete version[key]);
84
89
  Object.assign(version, next);
85
90
  return version;
86
91
  }
87
92
  async files(version) {
93
+ const stored = this.versions.get(version.hash);
88
94
  try {
89
- return JSON.parse(await (0, promises_1.readFile)(this.file(version.name, version.hash), 'utf8')).bundle;
95
+ return stored ? JSON.parse(await (0, promises_1.readFile)(stored.file, 'utf8')).bundle : undefined;
90
96
  }
91
97
  catch {
92
98
  return undefined;
@@ -94,11 +100,11 @@ class DefinitionStore {
94
100
  }
95
101
  // Reads a bundle the way a run would and says what it would be as a version;
96
102
  // `existing` is the published version with the same hash, if there is one.
97
- async prepare(name, bundle, publisher,
103
+ async prepare(bundle, publisher,
98
104
  // a proposal is read before anybody trusts it: it may not make the server fetch
99
105
  options = {}) {
100
106
  const hash = (0, bundle_1.hashBundle)(bundle);
101
- const existing = this.find(name, hash);
107
+ const existing = this.find(hash);
102
108
  if (existing) {
103
109
  return { version: existing, existing };
104
110
  }
@@ -108,11 +114,13 @@ class DefinitionStore {
108
114
  });
109
115
  return {
110
116
  version: {
111
- name,
112
117
  hash,
113
118
  entry: bundle.entry,
114
119
  publishedAt: new Date(this.now()).toISOString(),
115
120
  publishedBy: { iss: publisher.iss, sub: publisher.sub },
121
+ ...(options.annotations && Object.keys(options.annotations).length > 0
122
+ ? { annotations: { ...options.annotations } }
123
+ : {}),
116
124
  tasks,
117
125
  services,
118
126
  inputs,
@@ -121,9 +129,11 @@ class DefinitionStore {
121
129
  },
122
130
  };
123
131
  }
124
- // Publishing what already is there changes nothing and says so.
125
- async publish(name, bundle, publisher, approvedBy) {
126
- const prepared = await this.prepare(name, bundle, publisher);
132
+ // Publishing what already is there changes nothing and says so, its annotations
133
+ // included: they stay those of the first publish.
134
+ async publish(bundle, publisher, options = {}) {
135
+ const { approvedBy } = options;
136
+ const prepared = await this.prepare(bundle, publisher, { annotations: options.annotations });
127
137
  if (prepared.existing) {
128
138
  return { version: prepared.existing, created: false };
129
139
  }
@@ -132,28 +142,43 @@ class DefinitionStore {
132
142
  publishedAt: new Date(this.now()).toISOString(),
133
143
  ...(approvedBy ? { approvedBy: { iss: approvedBy.iss, sub: approvedBy.sub } } : {}),
134
144
  };
135
- const file = this.file(name, version.hash);
136
- await (0, promises_1.mkdir)((0, path_1.dirname)(file), { recursive: true });
137
- const temporary = `${file}.${(0, crypto_1.randomBytes)(8).toString('hex')}.tmp`;
138
- await (0, promises_1.writeFile)(temporary, JSON.stringify({ version, bundle }), { flag: 'wx' });
139
- await (0, promises_1.rename)(temporary, file);
140
- this.add(version);
145
+ if (!bundle_1.HASH_PATTERN.test(version.hash)) {
146
+ throw new plan_1.DefinitionError('invalid hash');
147
+ }
148
+ const file = this.fileOf(version.hash);
149
+ await (0, promises_1.mkdir)(this.directory, { recursive: true });
150
+ await this.write(file, version, bundle);
151
+ this.versions.set(version.hash, { version, file, legacy: false });
141
152
  return { version, created: true };
142
153
  }
143
- has(name) {
144
- return this.versions.has(name);
154
+ fileOf(hash) {
155
+ return (0, path_1.join)(this.directory, `${hash.slice('sha256:'.length)}.json`);
145
156
  }
146
- add(version) {
147
- const versions = this.versions.get(version.name) ?? [];
148
- versions.unshift(version);
149
- versions.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
150
- this.versions.set(version.name, versions);
151
- }
152
- file(name, hash) {
153
- if (!bundle_1.HASH_PATTERN.test(hash)) {
154
- throw new plan_1.DefinitionError('invalid hash');
157
+ async read(file, legacy) {
158
+ if (!file.endsWith('.json')) {
159
+ return;
160
+ }
161
+ const { version } = JSON.parse(await (0, promises_1.readFile)(file, 'utf8'));
162
+ // the name an older server kept with it means nothing now
163
+ delete version.name;
164
+ const stored = this.versions.get(version.hash);
165
+ if (stored && !stored.legacy) {
166
+ return; // `<hex>.json` is what was written last
167
+ }
168
+ // published under two names, it is still one version: yanked under either name, it
169
+ // stays yanked, else the first one counts
170
+ const known = stored?.version;
171
+ const first = !legacy ||
172
+ !known ||
173
+ (Boolean(version.yanked) === Boolean(known.yanked) && version.publishedAt < known.publishedAt);
174
+ if (first || (version.yanked && !known?.yanked)) {
175
+ this.versions.set(version.hash, { version, file, legacy });
155
176
  }
156
- return (0, path_1.join)(this.directory, name, `${hash.slice('sha256:'.length)}.json`);
177
+ }
178
+ async write(file, version, bundle) {
179
+ const temporary = `${file}.${(0, crypto_1.randomBytes)(8).toString('hex')}.tmp`;
180
+ await (0, promises_1.writeFile)(temporary, JSON.stringify({ version, bundle }), { flag: 'wx' });
181
+ await (0, promises_1.rename)(temporary, file);
157
182
  }
158
183
  }
159
184
  exports.DefinitionStore = DefinitionStore;