@backstage/plugin-scaffolder-backend 4.1.0 → 4.2.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.
package/CHANGELOG.md CHANGED
@@ -1,36 +1,52 @@
1
1
  # @backstage/plugin-scaffolder-backend
2
2
 
3
- ## 4.1.0
3
+ ## 4.2.0-next.1
4
4
 
5
5
  ### Minor Changes
6
6
 
7
- - 6fb2a41: Added the `scaffolder.requireScmUserCredentials` configuration option to require user-provided credentials for supported SCM mutation and fetch actions.
7
+ - bbba6b5: Added the `scaffolder.requireScmUserCredentials` configuration option to require user-provided credentials for supported SCM mutation and fetch actions.
8
8
 
9
9
  ### Patch Changes
10
10
 
11
- - 0253c50: Respect task read permission decisions when listing scaffolder tasks.
12
- - e673a86: Restrict task list ordering to supported fields.
13
- - 66d2219: Exclude internal task data from task responses.
14
- - 3eb3dc5: Improve denied scaffolder action error handling.
15
- - 3f1e869: Ensure task failure details use the configured log redactions.
16
- - dc30fae: Fixed log redaction for transformed secret values used to iterate Scaffolder steps.
17
- - 9e86c95: Restrict task retries to terminal states.
18
- - a91ed72: Fixed matching of action input values in scaffolder permission policies.
11
+ - 7fba55a: Removed user entity references from scaffolder task count metrics to avoid exposing user identities and creating high-cardinality metric labels.
12
+ - b1256aa: Respect task read permission decisions when listing scaffolder tasks.
13
+ - 15fa029: Restrict task list ordering to supported fields.
14
+ - be0a75a: Exclude internal task data from task responses.
15
+ - f389dd2: Improve denied scaffolder action error handling.
16
+ - 2eebeb7: Ensure task failure details use the configured log redactions.
17
+ - 84ebbb9: Fixed log redaction for transformed secret values used to iterate Scaffolder steps.
18
+ - b4172dd: Restrict task retries to terminal states.
19
+ - ee9c48d: Fixed matching of action input values in scaffolder permission policies.
20
+ - 79f0b91: Fixed the scaffolder task worker silently giving up after a transient failure. A single error while picking up a task, such as a dropped database connection, would stop the backend from running any further software templates for the rest of its lifetime. New tasks stayed queued indefinitely with no error shown to the user and no failing health check, and the only way to recover was to restart the backend. Picking up tasks is now retried instead.
19
21
  - Updated dependencies
20
- - @backstage/plugin-scaffolder-common@2.2.3
21
- - @backstage/integration@2.1.1
22
+ - @backstage/plugin-scaffolder-common@2.3.0-next.1
23
+ - @backstage/integration@2.1.2-next.0
24
+ - @backstage/backend-openapi-utils@0.7.2-next.0
25
+ - @backstage/plugin-permission-node@0.11.4-next.0
26
+ - @backstage/plugin-scaffolder-node@0.13.7-next.1
27
+ - @backstage/backend-plugin-api@1.10.1-next.0
28
+ - @backstage/plugin-catalog-node@2.2.5-next.0
29
+ - @backstage/plugin-events-node@0.4.26-next.0
22
30
 
23
- ## 4.0.5
31
+ ## 4.1.0-next.0
24
32
 
25
- ### Patch Changes
33
+ ### Minor Changes
26
34
 
27
- - 640372d: Software template inline conditionals without an `else` branch now render an empty string when their condition is false, matching Nunjucks behavior.
35
+ - 1a705ca: Applied `templateDryRunPermission` to inline Software Template dry runs and the corresponding backend action. Permission policies that deny unknown permissions must explicitly allow `scaffolder.template.dry-run` to retain existing dry-run access.
36
+ - e95b649: Added task recovery feature with new `scaffolder.taskRecovery` config section. When enabled, tasks that crash or timeout are automatically recovered and resume from the last completed step, task secrets are retained until the task reaches a terminal state so recovery can continue, and completed step outputs are persisted. Enabling recovery applies to all scaffolder tasks, so actions used by those tasks should be idempotent or use checkpoints. When recovery is disabled (the default), the previous behavior is unchanged: secrets are cleared as soon as a task is claimed and retries re-run every step. The new config consolidates previous experimental flags (`EXPERIMENTAL_recoverTasks`, `EXPERIMENTAL_workspaceSerialization`, `EXPERIMENTAL_recoverTasksTimeout`) which remain supported as fallbacks. The legacy workspace provider setting continues to select a provider only when `EXPERIMENTAL_workspaceSerialization` is `true`.
28
37
 
29
- ## 4.0.4
38
+ Workspace serialization for task recovery now requires installing a separate workspace provider module, including when you use the legacy configuration. For development, use `@backstage/plugin-scaffolder-backend-module-workspace-database` (50 MB limit, not recommended for production). On first startup, that module migrates existing database workspace snapshots from the legacy task storage. For production, use `@backstage/plugin-scaffolder-backend-module-gcp` or a similar external storage provider. The scaffolder rejects a configured provider that has not been installed and registered.
39
+
40
+ Enabling crash recovery does not keep completed task event streams open; normal task completion remains terminal for event-stream clients.
30
41
 
31
42
  ### Patch Changes
32
43
 
33
- - 913634d: Restored support for intrinsic string, number, array, `Map`, and `Set` methods in software templates.
44
+ - 2bf1392: Software template inline conditionals without an `else` branch now render an empty string when their condition is false, matching Nunjucks behavior.
45
+ - beaa3db: Reject Scaffolder steps where `each` resolves to a primitive value instead of an array or object.
46
+ - c1a30ef: Restored support for intrinsic string, number, array, `Map`, and `Set` methods in software templates.
47
+ - Updated dependencies
48
+ - @backstage/plugin-scaffolder-common@2.3.0-next.0
49
+ - @backstage/plugin-scaffolder-node@0.13.7-next.0
34
50
 
35
51
  ## 4.0.3
36
52
 
package/README.md CHANGED
@@ -124,3 +124,26 @@ Default secrets are resolved from environment variables and accessible via `${{
124
124
  ```
125
125
 
126
126
  **Security Note:** Secrets are automatically masked in logs and are only available to backend actions, never exposed to the frontend.
127
+
128
+ ### Task Recovery
129
+
130
+ The scaffolder supports automatic task recovery when workers restart or crash. When enabled, tasks that were in a `processing` state will be recovered and can be resumed from where they left off.
131
+
132
+ ```yaml
133
+ scaffolder:
134
+ taskRecovery:
135
+ enabled: true
136
+ staleTimeout: { seconds: 30 } # Optional: how long before a task is considered stale
137
+ workspaceProvider: database # Optional: enables workspace serialization with specified provider
138
+ ```
139
+
140
+ When task recovery is enabled:
141
+
142
+ - Tasks in `processing` state with stale heartbeats are automatically recovered to `open` state
143
+ - Secrets are preserved until the task reaches a terminal state (completed/failed)
144
+ - Completed steps are skipped on retry, resuming from the last incomplete step
145
+ - Step outputs are restored so subsequent steps can access previous results
146
+
147
+ **Note:** `workspaceProvider` is a separate option from `enabled`. Setting `enabled: true` activates task recovery (secrets preservation, step skipping), but workspace serialization only happens if you also set `workspaceProvider`. The `workspaceProvider` value specifies which storage backend to use (for example, `database` from `@backstage/plugin-scaffolder-backend-module-workspace-database`, or `gcpBucket` from `@backstage/plugin-scaffolder-backend-module-gcp`). Install and register the corresponding module before selecting a provider.
148
+
149
+ This replaces the previous experimental flags (`EXPERIMENTAL_recoverTasks`, `EXPERIMENTAL_workspaceSerialization`, `EXPERIMENTAL_recoverTasksTimeout`) which are still supported as fallbacks. Legacy workspace serialization also requires a separately installed provider module.
@@ -30,6 +30,62 @@
30
30
  "type": "number",
31
31
  "description": "Sets the number of concurrent tasks that can be run at any given time on the TaskWorker.\n\nDefaults to 10.\n\nSet to 0 to disable task workers altogether."
32
32
  },
33
+ "taskRecovery": {
34
+ "type": "object",
35
+ "properties": {
36
+ "enabled": {
37
+ "type": "boolean",
38
+ "description": "Enable automatic task recovery. When enabled:\n- Secrets are preserved until task completion\n- Step state is saved after each step\n- Crashed tasks resume from last completed step",
39
+ "visibility": "backend"
40
+ },
41
+ "staleTimeout": {
42
+ "anyOf": [
43
+ {
44
+ "type": "object",
45
+ "properties": {
46
+ "years": {
47
+ "type": "number"
48
+ },
49
+ "months": {
50
+ "type": "number"
51
+ },
52
+ "weeks": {
53
+ "type": "number"
54
+ },
55
+ "days": {
56
+ "type": "number"
57
+ },
58
+ "hours": {
59
+ "type": "number"
60
+ },
61
+ "minutes": {
62
+ "type": "number"
63
+ },
64
+ "seconds": {
65
+ "type": "number"
66
+ },
67
+ "milliseconds": {
68
+ "type": "number"
69
+ }
70
+ },
71
+ "description": "Human friendly durations object."
72
+ },
73
+ {
74
+ "type": "string"
75
+ }
76
+ ],
77
+ "description": "How long before a task is considered stale and eligible for recovery. Default: 30 seconds",
78
+ "visibility": "backend"
79
+ },
80
+ "workspaceProvider": {
81
+ "type": "string",
82
+ "description": "The workspace provider to use for serializing and restoring workspaces. Setting this value enables workspace serialization. If not set, workspace serialization is disabled. Common values: 'database'",
83
+ "visibility": "backend"
84
+ }
85
+ },
86
+ "description": "Task recovery configuration",
87
+ "visibility": "backend"
88
+ },
33
89
  "requireScmUserCredentials": {
34
90
  "type": "boolean",
35
91
  "description": "Requires supported SCM actions to only operate with credentials explicitly provided by the signed-in user.\n\nDefaults to false."
@@ -40,15 +96,18 @@
40
96
  },
41
97
  "EXPERIMENTAL_recoverTasks": {
42
98
  "type": "boolean",
43
- "description": "Sets the tasks recoverability on system start up.\n\nIf not specified, the default value is false."
99
+ "description": "Sets the tasks recoverability on system start up.\n\nIf not specified, the default value is false.",
100
+ "deprecated": "Use scaffolder.taskRecovery.enabled instead."
44
101
  },
45
102
  "EXPERIMENTAL_workspaceSerialization": {
46
103
  "type": "boolean",
47
- "description": "Sets the serialization of the workspace to have an ability to rerun the failed task."
104
+ "description": "Sets the serialization of the workspace to have an ability to rerun the failed task.",
105
+ "deprecated": "Use scaffolder.taskRecovery.workspaceProvider instead."
48
106
  },
49
107
  "EXPERIMENTAL_workspaceSerializationProvider": {
50
108
  "type": "string",
51
- "description": "Sets the provider for workspace serialization.\n\nBy default, it is your database."
109
+ "description": "Sets the provider for workspace serialization.\n\nDefaults to the database provider when workspace serialization is enabled.",
110
+ "deprecated": "Use scaffolder.taskRecovery.workspaceProvider instead."
52
111
  },
53
112
  "EXPERIMENTAL_recoverTasksTimeout": {
54
113
  "anyOf": [
@@ -86,7 +145,8 @@
86
145
  "type": "string"
87
146
  }
88
147
  ],
89
- "description": "Every task which is in progress state and having a last heartbeat longer than a specified timeout is going to be attempted to recover.\n\nIf not specified, the default value is 5 seconds."
148
+ "description": "Every task which is in progress state and having a last heartbeat longer than a specified timeout is going to be attempted to recover.\n\nIf not specified, the default value is 30 seconds.",
149
+ "deprecated": "Use scaffolder.taskRecovery.staleTimeout instead."
90
150
  },
91
151
  "taskTimeoutJanitorFrequency": {
92
152
  "anyOf": [
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ var alpha = require('@backstage/plugin-scaffolder-common/alpha');
3
4
  var yaml = require('yaml');
4
5
 
5
6
  function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
@@ -26,6 +27,7 @@ const createDryRunTemplateAction = ({
26
27
  idempotent: true
27
28
  },
28
29
  description: "Dry-runs a scaffolder template to validate it without making changes. Returns success with execution logs, or errors for validation failures.",
30
+ visibilityPermission: alpha.templateDryRunPermission,
29
31
  schema: {
30
32
  input: (z) => z.object({
31
33
  templateYaml: z.string().describe(
@@ -1 +1 @@
1
- {"version":3,"file":"createDryRunTemplateAction.cjs.js","sources":["../../src/actions/createDryRunTemplateAction.ts"],"sourcesContent":["/*\n * Copyright 2026 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';\nimport { ScaffolderService } from '@backstage/plugin-scaffolder-node';\nimport { JsonObject } from '@backstage/types';\nimport yaml from 'yaml';\n\nconst MAX_CONTENT_SIZE = 64 * 1024;\n\nfunction base64EncodeContent(content: string): string {\n if (content.length > MAX_CONTENT_SIZE) {\n return Buffer.from('<file too large>', 'utf8').toString('base64');\n }\n return Buffer.from(content, 'utf8').toString('base64');\n}\n\nexport const createDryRunTemplateAction = ({\n actionsRegistry,\n scaffolderService,\n}: {\n actionsRegistry: ActionsRegistryService;\n scaffolderService: ScaffolderService;\n}) => {\n actionsRegistry.register({\n name: 'dry-run-template',\n title: 'Dry Run Scaffolder Template',\n attributes: {\n destructive: false,\n readOnly: true,\n idempotent: true,\n },\n description:\n 'Dry-runs a scaffolder template to validate it without making changes. Returns success with execution logs, or errors for validation failures.',\n schema: {\n input: z =>\n z.object({\n templateYaml: z\n .string()\n .describe(\n 'The YAML content of the scaffolder template to validate',\n ),\n values: z\n .record(z.unknown())\n .optional()\n .describe('Input values for template parameters'),\n files: z\n .array(\n z.object({\n path: z\n .string()\n .describe('File path relative to template root'),\n content: z.string().describe('File content'),\n }),\n )\n .optional()\n .describe('Files required for running the template'),\n }),\n output: z =>\n z.object({\n valid: z.boolean().describe('Whether the template is valid'),\n message: z\n .string()\n .describe('Success message or validation error details'),\n errors: z\n .array(z.string())\n .optional()\n .describe('List of validation errors'),\n log: z\n .array(\n z.object({\n message: z.string(),\n stepId: z.string().optional(),\n status: z.string().optional(),\n }),\n )\n .optional()\n .describe('Execution log from dry-run'),\n output: z\n .record(z.unknown())\n .optional()\n .describe('Template output values'),\n steps: z\n .array(\n z.object({\n id: z.string(),\n name: z.string(),\n action: z.string(),\n }),\n )\n .optional()\n .describe('Parsed template steps'),\n }),\n },\n action: async ({ input, credentials }) => {\n const { templateYaml, values = {}, files = [] } = input;\n\n let template;\n try {\n template = yaml.parse(templateYaml);\n } catch (parseError) {\n const yamlError = parseError as yaml.YAMLParseError;\n return {\n output: {\n valid: false,\n message: 'Failed to parse YAML template',\n errors: [\n `YAML parsing error: ${yamlError.message}`,\n yamlError.linePos\n ? `At line ${yamlError.linePos[0].line}, column ${yamlError.linePos[0].col}`\n : '',\n ].filter(Boolean),\n },\n };\n }\n\n const result = await scaffolderService.dryRun(\n {\n template,\n values: values as JsonObject,\n directoryContents: files.map(file => ({\n path: file.path,\n base64Content: base64EncodeContent(file.content),\n })),\n },\n { credentials },\n );\n\n return {\n output: {\n valid: true,\n message: 'Template validation successful',\n log: result.log?.map(entry => ({\n message: entry.body.message,\n stepId: entry.body.stepId,\n status: entry.body.status,\n })),\n output: result.output,\n steps: result.steps,\n },\n };\n },\n });\n};\n"],"names":["yaml"],"mappings":"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"}
1
+ {"version":3,"file":"createDryRunTemplateAction.cjs.js","sources":["../../src/actions/createDryRunTemplateAction.ts"],"sourcesContent":["/*\n * Copyright 2026 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';\nimport { templateDryRunPermission } from '@backstage/plugin-scaffolder-common/alpha';\nimport { ScaffolderService } from '@backstage/plugin-scaffolder-node';\nimport { JsonObject } from '@backstage/types';\nimport yaml from 'yaml';\n\nconst MAX_CONTENT_SIZE = 64 * 1024;\n\nfunction base64EncodeContent(content: string): string {\n if (content.length > MAX_CONTENT_SIZE) {\n return Buffer.from('<file too large>', 'utf8').toString('base64');\n }\n return Buffer.from(content, 'utf8').toString('base64');\n}\n\nexport const createDryRunTemplateAction = ({\n actionsRegistry,\n scaffolderService,\n}: {\n actionsRegistry: ActionsRegistryService;\n scaffolderService: ScaffolderService;\n}) => {\n actionsRegistry.register({\n name: 'dry-run-template',\n title: 'Dry Run Scaffolder Template',\n attributes: {\n destructive: false,\n readOnly: true,\n idempotent: true,\n },\n description:\n 'Dry-runs a scaffolder template to validate it without making changes. Returns success with execution logs, or errors for validation failures.',\n visibilityPermission: templateDryRunPermission,\n schema: {\n input: z =>\n z.object({\n templateYaml: z\n .string()\n .describe(\n 'The YAML content of the scaffolder template to validate',\n ),\n values: z\n .record(z.unknown())\n .optional()\n .describe('Input values for template parameters'),\n files: z\n .array(\n z.object({\n path: z\n .string()\n .describe('File path relative to template root'),\n content: z.string().describe('File content'),\n }),\n )\n .optional()\n .describe('Files required for running the template'),\n }),\n output: z =>\n z.object({\n valid: z.boolean().describe('Whether the template is valid'),\n message: z\n .string()\n .describe('Success message or validation error details'),\n errors: z\n .array(z.string())\n .optional()\n .describe('List of validation errors'),\n log: z\n .array(\n z.object({\n message: z.string(),\n stepId: z.string().optional(),\n status: z.string().optional(),\n }),\n )\n .optional()\n .describe('Execution log from dry-run'),\n output: z\n .record(z.unknown())\n .optional()\n .describe('Template output values'),\n steps: z\n .array(\n z.object({\n id: z.string(),\n name: z.string(),\n action: z.string(),\n }),\n )\n .optional()\n .describe('Parsed template steps'),\n }),\n },\n action: async ({ input, credentials }) => {\n const { templateYaml, values = {}, files = [] } = input;\n\n let template;\n try {\n template = yaml.parse(templateYaml);\n } catch (parseError) {\n const yamlError = parseError as yaml.YAMLParseError;\n return {\n output: {\n valid: false,\n message: 'Failed to parse YAML template',\n errors: [\n `YAML parsing error: ${yamlError.message}`,\n yamlError.linePos\n ? `At line ${yamlError.linePos[0].line}, column ${yamlError.linePos[0].col}`\n : '',\n ].filter(Boolean),\n },\n };\n }\n\n const result = await scaffolderService.dryRun(\n {\n template,\n values: values as JsonObject,\n directoryContents: files.map(file => ({\n path: file.path,\n base64Content: base64EncodeContent(file.content),\n })),\n },\n { credentials },\n );\n\n return {\n output: {\n valid: true,\n message: 'Template validation successful',\n log: result.log?.map(entry => ({\n message: entry.body.message,\n stepId: entry.body.stepId,\n status: entry.body.status,\n })),\n output: result.output,\n steps: result.steps,\n },\n };\n },\n });\n};\n"],"names":["templateDryRunPermission","yaml"],"mappings":"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"}
@@ -6,7 +6,6 @@ var node_crypto = require('node:crypto');
6
6
  var luxon = require('luxon');
7
7
  var taskRecoveryHelper = require('./taskRecoveryHelper.cjs.js');
8
8
  var dbUtil = require('./dbUtil.cjs.js');
9
- var alpha = require('@backstage/plugin-scaffolder-node/alpha');
10
9
  var helpers = require('../../service/helpers.cjs.js');
11
10
  var pluginPermissionNode = require('@backstage/plugin-permission-node');
12
11
  var lodash = require('lodash');
@@ -33,11 +32,16 @@ const parseSqlDateToIsoString = (input) => {
33
32
  class DatabaseTaskStore {
34
33
  db;
35
34
  events;
35
+ recoverTasksEnabled;
36
36
  static async create(options) {
37
37
  const { database } = options;
38
38
  const client = await this.getClient(database);
39
39
  await this.runMigrations(database, client);
40
- return new DatabaseTaskStore(client, options.events);
40
+ return new DatabaseTaskStore(
41
+ client,
42
+ options.events,
43
+ options.recoverTasksEnabled ?? false
44
+ );
41
45
  }
42
46
  isRecoverableTask(spec) {
43
47
  return ["startOver"].includes(
@@ -79,9 +83,10 @@ class DatabaseTaskStore {
79
83
  });
80
84
  }
81
85
  }
82
- constructor(client, events) {
86
+ constructor(client, events, recoverTasksEnabled) {
83
87
  this.db = client;
84
88
  this.events = events;
89
+ this.recoverTasksEnabled = recoverTasksEnabled;
85
90
  }
86
91
  getState(task) {
87
92
  try {
@@ -163,7 +168,14 @@ class DatabaseTaskStore {
163
168
  queryBuilder.offset(pagination.offset);
164
169
  }
165
170
  const [results, [{ count }]] = await Promise.all([
166
- queryBuilder.select(),
171
+ queryBuilder.select([
172
+ "id",
173
+ "spec",
174
+ "status",
175
+ "created_by",
176
+ "last_heartbeat_at",
177
+ "created_at"
178
+ ]),
167
179
  countQuery
168
180
  ]);
169
181
  const tasks = results.map((result) => ({
@@ -183,7 +195,16 @@ class DatabaseTaskStore {
183
195
  return { tasks, totalTasks };
184
196
  }
185
197
  async getTask(taskId) {
186
- const [result] = await this.db("tasks").where({ id: taskId }).select();
198
+ const [result] = await this.db("tasks").where({ id: taskId }).select([
199
+ "id",
200
+ "spec",
201
+ "status",
202
+ "state",
203
+ "last_heartbeat_at",
204
+ "created_at",
205
+ "created_by",
206
+ "secrets"
207
+ ]);
187
208
  if (!result) {
188
209
  throw new errors.NotFoundError(`No task with id '${taskId}' found`);
189
210
  }
@@ -232,7 +253,16 @@ class DatabaseTaskStore {
232
253
  return this.db.transaction(async (tx) => {
233
254
  const [task] = await tx("tasks").where({
234
255
  status: "open"
235
- }).limit(1).select();
256
+ }).limit(1).select([
257
+ "id",
258
+ "spec",
259
+ "status",
260
+ "state",
261
+ "last_heartbeat_at",
262
+ "created_at",
263
+ "created_by",
264
+ "secrets"
265
+ ]);
236
266
  if (!task) {
237
267
  return void 0;
238
268
  }
@@ -240,8 +270,11 @@ class DatabaseTaskStore {
240
270
  const updateCount = await tx("tasks").where({ id: task.id, status: "open" }).update({
241
271
  status: "processing",
242
272
  last_heartbeat_at: this.db.fn.now(),
243
- // remove the secrets for non-recoverable tasks when moving to processing state.
244
- secrets: this.isRecoverableTask(spec) ? task.secrets : null
273
+ // Retain secrets only when recovery is enabled (globally or via the
274
+ // legacy per-template strategy) so recovered tasks can resume.
275
+ // Otherwise clear them on claim to preserve the default security
276
+ // lifecycle.
277
+ secrets: this.recoverTasksEnabled || this.isRecoverableTask(spec) ? task.secrets : null
245
278
  });
246
279
  if (updateCount < 1) {
247
280
  return void 0;
@@ -274,7 +307,7 @@ class DatabaseTaskStore {
274
307
  async listStaleTasks(options) {
275
308
  const { timeoutS } = options;
276
309
  const heartbeatInterval = dbUtil.intervalFromNowTill(timeoutS, this.db);
277
- const rawRows = await this.db("tasks").where("status", "processing").andWhere("last_heartbeat_at", "<=", heartbeatInterval);
310
+ const rawRows = await this.db("tasks").where("status", "processing").andWhere("last_heartbeat_at", "<=", heartbeatInterval).select(["id", "spec"]);
278
311
  const tasks = rawRows.map((row) => ({
279
312
  recovery: JSON.parse(row.spec).EXPERIMENTAL_recovery,
280
313
  taskId: row.id
@@ -294,7 +327,14 @@ class DatabaseTaskStore {
294
327
  await this.db.transaction(async (tx) => {
295
328
  const [task] = await tx("tasks").where({
296
329
  id: taskId
297
- }).limit(1).select();
330
+ }).limit(1).select([
331
+ "id",
332
+ "status",
333
+ "state",
334
+ "last_heartbeat_at",
335
+ "created_at",
336
+ "created_by"
337
+ ]);
298
338
  const updateTask = async (criteria) => {
299
339
  const updateCount = await tx("tasks").where(criteria).update({
300
340
  status,
@@ -367,7 +407,7 @@ class DatabaseTaskStore {
367
407
  }
368
408
  }
369
409
  async listEvents(options) {
370
- const { isTaskRecoverable, taskId, after } = options;
410
+ const { taskId, after, isTaskRecoverable } = options;
371
411
  const rawEvents = await this.db("task_events").where({
372
412
  task_id: taskId
373
413
  }).andWhere((builder) => {
@@ -422,34 +462,18 @@ class DatabaseTaskStore {
422
462
  }
423
463
  });
424
464
  }
425
- async rehydrateWorkspace(options) {
426
- const [result] = await this.db("tasks").where({ id: options.taskId }).select("workspace");
427
- await alpha.restoreWorkspace({
428
- path: options.targetPath,
429
- buffer: result.workspace
430
- });
431
- }
432
- async cleanWorkspace({ taskId }) {
433
- await this.db("tasks").where({ id: taskId }).update({
434
- workspace: null
435
- });
436
- }
437
- async serializeWorkspace(options) {
438
- if (options.path) {
439
- const workspace = (await alpha.serializeWorkspace(options)).contents;
440
- await this.db("tasks").where({ id: options.taskId }).update({
441
- workspace
442
- });
443
- }
444
- }
445
465
  async cancelTask(options) {
446
466
  const { taskId, body } = options;
447
467
  const serializedBody = JSON.stringify(body);
448
- const [ret] = await this.db("task_events").insert({
449
- task_id: taskId,
450
- event_type: "cancelled",
451
- body: serializedBody
452
- }).returning("id");
468
+ const ret = await this.db.transaction(async (tx) => {
469
+ await tx("tasks").where({ id: taskId }).update({ secrets: null });
470
+ const [event] = await tx("task_events").insert({
471
+ task_id: taskId,
472
+ event_type: "cancelled",
473
+ body: serializedBody
474
+ }).returning("id");
475
+ return event;
476
+ });
453
477
  this.events?.publish({
454
478
  topic: "scaffolder.task",
455
479
  eventPayload: {