wood-fired-tasks 2.1.1 → 2.2.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 (114) hide show
  1. package/CHANGELOG.md +57 -3
  2. package/README.md +2 -1
  3. package/SECURITY.md +2 -2
  4. package/dist/api/api-response.d.ts +4 -0
  5. package/dist/api/routes/models/index.d.ts +16 -0
  6. package/dist/api/routes/models/index.js +1 -7
  7. package/dist/api/routes/projects/resolve-model.js +17 -8
  8. package/dist/api/routes/projects/wsjf.js +4 -0
  9. package/dist/api/routes/settings/model-policy.js +8 -4
  10. package/dist/api/routes/tasks/schemas.d.ts +8 -0
  11. package/dist/api/routes/tasks/schemas.js +10 -0
  12. package/dist/api/server.d.ts +1 -1
  13. package/dist/api/server.js +6 -19
  14. package/dist/cli/api/client.d.ts +7 -7
  15. package/dist/cli/api/types.d.ts +6 -0
  16. package/dist/cli/auth/browser-open.js +0 -11
  17. package/dist/cli/auth/credentials.d.ts +19 -0
  18. package/dist/cli/auth/credentials.js +30 -0
  19. package/dist/cli/commands/comment-add.js +8 -1
  20. package/dist/cli/commands/create.js +9 -1
  21. package/dist/cli/commands/list.js +30 -7
  22. package/dist/cli/commands/login.d.ts +10 -0
  23. package/dist/cli/commands/login.js +2 -1
  24. package/dist/cli/commands/models.d.ts +38 -3
  25. package/dist/cli/commands/models.js +48 -4
  26. package/dist/cli/commands/project-set-models.js +6 -19
  27. package/dist/cli/commands/settings-set-models.js +6 -19
  28. package/dist/cli/commands/setup.d.ts +8 -0
  29. package/dist/cli/commands/setup.js +1 -0
  30. package/dist/cli/commands/update.js +20 -0
  31. package/dist/events/event-bus.d.ts +2 -1
  32. package/dist/events/event-bus.js +1 -0
  33. package/dist/events/sse-manager.d.ts +30 -1
  34. package/dist/events/sse-manager.js +100 -18
  35. package/dist/events/types.d.ts +19 -2
  36. package/dist/events/types.js +1 -0
  37. package/dist/index.d.ts +10 -0
  38. package/dist/index.js +36 -1
  39. package/dist/mcp/index.js +5 -24
  40. package/dist/mcp/lib/model-tool-definitions.d.ts +122 -0
  41. package/dist/mcp/lib/model-tool-definitions.js +112 -0
  42. package/dist/mcp/remote/register-tools.js +14 -73
  43. package/dist/mcp/remote/rest-client.d.ts +4 -10
  44. package/dist/mcp/resources/events.js +2 -1
  45. package/dist/mcp/tools/health-tools.d.ts +14 -0
  46. package/dist/mcp/tools/health-tools.js +52 -1
  47. package/dist/mcp/tools/model-tools.d.ts +4 -3
  48. package/dist/mcp/tools/model-tools.js +13 -82
  49. package/dist/mcp/tools/task-tools.js +5 -1
  50. package/dist/repositories/interfaces.d.ts +39 -0
  51. package/dist/repositories/project-charter-history.repository.js +2 -12
  52. package/dist/repositories/project.repository.js +16 -48
  53. package/dist/repositories/task.repository.d.ts +48 -0
  54. package/dist/repositories/task.repository.js +90 -18
  55. package/dist/repositories/wsjf-history.repository.js +6 -16
  56. package/dist/schemas/model-catalog.schema.d.ts +22 -0
  57. package/dist/schemas/model-catalog.schema.js +21 -0
  58. package/dist/schemas/model-policy.schema.d.ts +14 -0
  59. package/dist/schemas/model-policy.schema.js +11 -0
  60. package/dist/schemas/task.schema.d.ts +13 -1
  61. package/dist/schemas/task.schema.js +11 -0
  62. package/dist/services/claim-release.service.d.ts +11 -0
  63. package/dist/services/claim-release.service.js +31 -2
  64. package/dist/services/job-size-backfill.d.ts +43 -0
  65. package/dist/services/job-size-backfill.js +81 -0
  66. package/dist/services/model-catalog.service.d.ts +28 -8
  67. package/dist/services/model-catalog.service.js +16 -4
  68. package/dist/services/model-policy.service.d.ts +87 -9
  69. package/dist/services/model-policy.service.js +96 -4
  70. package/dist/services/settings.service.d.ts +3 -2
  71. package/dist/services/settings.service.js +30 -10
  72. package/dist/services/task.service.d.ts +87 -3
  73. package/dist/services/task.service.js +351 -16
  74. package/dist/services/wsjf-health.service.d.ts +7 -1
  75. package/dist/services/wsjf-health.service.js +27 -0
  76. package/dist/services/wsjf.service.d.ts +19 -0
  77. package/dist/services/wsjf.service.js +33 -0
  78. package/dist/skills/tasks/blocked.md +2 -1
  79. package/dist/skills/tasks/decompose.md +30 -4
  80. package/dist/skills/tasks/loop-dag.md +3 -1
  81. package/dist/skills/tasks/loop.md +1 -1
  82. package/dist/slack/notifier.js +1 -0
  83. package/dist/slack/task-formatter.js +1 -0
  84. package/dist/types/task.d.ts +12 -17
  85. package/dist/types/wsjf.d.ts +1 -1
  86. package/dist/types/wsjf.js +2 -0
  87. package/dist/utils/parse-json-column.d.ts +25 -0
  88. package/dist/utils/parse-json-column.js +39 -0
  89. package/docs/API.md +23 -10
  90. package/docs/ARCHITECTURE.md +16 -14
  91. package/docs/CLI.md +11 -12
  92. package/docs/INTERFACES.md +1 -1
  93. package/docs/MCP.md +105 -4
  94. package/docs/SLACK.md +1 -0
  95. package/package.json +1 -1
  96. package/packages/wft-router/README.md +12 -0
  97. package/packages/wft-router/dist/bin/wft-router.d.ts +10 -0
  98. package/packages/wft-router/dist/bin/wft-router.js +18 -0
  99. package/packages/wft-router/dist/config/event-types.d.ts +1 -1
  100. package/packages/wft-router/dist/config/event-types.js +1 -0
  101. package/packages/wft-router/dist/config/triggers-schema.d.ts +9 -0
  102. package/packages/wft-router/dist/config/triggers-schema.js +10 -0
  103. package/packages/wft-router/dist/daemon.d.ts +36 -0
  104. package/packages/wft-router/dist/daemon.js +101 -2
  105. package/packages/wft-router/dist/dispatch/startup-sweep.d.ts +96 -0
  106. package/packages/wft-router/dist/dispatch/startup-sweep.js +163 -0
  107. package/packages/wft-router/dist/index.d.ts +2 -2
  108. package/packages/wft-router/dist/index.js +1 -1
  109. package/packages/wft-router/dist/metrics.d.ts +64 -4
  110. package/packages/wft-router/dist/metrics.js +0 -0
  111. package/packages/wft-router/dist/sse/client.d.ts +19 -0
  112. package/packages/wft-router/dist/sse/client.js +57 -3
  113. package/packages/wft-router/dist/sse/index.d.ts +1 -1
  114. package/packages/wft-router/dist/sse/index.js +1 -1
@@ -48,6 +48,7 @@ function emitJsonEvent(event) {
48
48
  */
49
49
  export async function runDeviceLogin(args) {
50
50
  const { baseUrl, clientId, hostname, tokenName, openBrowser: shouldOpenBrowser, isJson } = args;
51
+ const opener = args.opener ?? openBrowser;
51
52
  // 2. Request a device_code from the server.
52
53
  let codeResponse;
53
54
  try {
@@ -99,7 +100,7 @@ export async function runDeviceLogin(args) {
99
100
  }
100
101
  // 4. Best-effort browser launch (skipped if !openBrowser).
101
102
  if (shouldOpenBrowser) {
102
- const opened = openBrowser(verification_uri_complete);
103
+ const opened = opener(verification_uri_complete);
103
104
  if (!isJson) {
104
105
  if (opened) {
105
106
  process.stderr.write('(Opening browser...)\n');
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { type ModelPolicy } from '../../schemas/model-policy.schema.js';
2
+ import { type ModelPolicy, type PipelineRole } from '../../schemas/model-policy.schema.js';
3
3
  /**
4
4
  * Configurable Task Models (Task 12) — CLI surface.
5
5
  *
@@ -14,9 +14,13 @@ import { type ModelPolicy } from '../../schemas/model-policy.schema.js';
14
14
  * `settings-set-models.ts`). They turn the flat Commander option bag into a
15
15
  * validated partial `ModelPolicy` (`ModelPolicySchema.parse`).
16
16
  */
17
- /** The three dispatch roles a policy can configure. */
17
+ /**
18
+ * The three dispatch roles a policy can configure. Derived from the
19
+ * single-source `PIPELINE_ROLES` (task #929); kept under the CLI-local names
20
+ * so existing call sites are undisturbed.
21
+ */
18
22
  export declare const MODEL_ROLES: readonly ["execution", "validation", "planning"];
19
- export type ModelRole = (typeof MODEL_ROLES)[number];
23
+ export type ModelRole = PipelineRole;
20
24
  /**
21
25
  * Register the per-role model flags on a Commander command:
22
26
  * --<role>-<category> <model|auto> (byCategory route)
@@ -41,4 +45,35 @@ export declare function buildModelPolicyFromOptions(options: Record<string, stri
41
45
  * `set-models` UX. Returns a schema-validated complete policy to persist.
42
46
  */
43
47
  export declare function mergeModelPolicies(existing: ModelPolicy | null | undefined, partial: ModelPolicy): ModelPolicy;
48
+ /**
49
+ * Read `--json` off the GLOBAL program options for a set-models subcommand.
50
+ * The flag is registered on the root program, so subcommands must reach up via
51
+ * `optsWithGlobals()`. Shared by `project-set-models` / `settings-set-models`.
52
+ */
53
+ export declare function resolveSetModelsJsonMode(command: Command): boolean;
54
+ /**
55
+ * Outcome of {@link parseSetModelsOptions}: either a validated partial policy
56
+ * ready to merge+persist, or a `stop` sentinel meaning the command already
57
+ * emitted its message and set `process.exitCode = 1` — the caller just returns.
58
+ */
59
+ export type ParseSetModelsResult = {
60
+ stop: true;
61
+ } | {
62
+ stop: false;
63
+ policy: ModelPolicy;
64
+ };
65
+ /**
66
+ * Shared set-models preamble for `project-set-models` / `settings-set-models`:
67
+ *
68
+ * 1. Assemble + validate a partial policy from the flat option bag
69
+ * (`buildModelPolicyFromOptions`). On a validation error, print it and
70
+ * set `process.exitCode = 1`, returning `{ stop: true }`.
71
+ * 2. When no model flags were supplied, warn and set `process.exitCode = 1`,
72
+ * returning `{ stop: true }`.
73
+ * 3. Otherwise return `{ stop: false, policy }`.
74
+ *
75
+ * Extracting this keeps the two commands' parse/validate/no-flags behavior and
76
+ * output byte-identical from one source.
77
+ */
78
+ export declare function parseSetModelsOptions(options: Record<string, string | undefined>): ParseSetModelsResult;
44
79
  export declare const modelsCommand: Command;
@@ -1,9 +1,9 @@
1
1
  import { Command } from 'commander';
2
2
  import { listModels } from '../api/client.js';
3
- import { colorWarn } from '../output/formatters.js';
3
+ import { colorError, colorWarn } from '../output/formatters.js';
4
4
  import { handleError } from '../output/error-handler.js';
5
5
  import { jsonOutput } from '../output/json-output.js';
6
- import { POWER_CATEGORIES, ModelPolicySchema, } from '../../schemas/model-policy.schema.js';
6
+ import { PIPELINE_ROLES, POWER_CATEGORIES, ModelPolicySchema, } from '../../schemas/model-policy.schema.js';
7
7
  /**
8
8
  * Configurable Task Models (Task 12) — CLI surface.
9
9
  *
@@ -18,8 +18,12 @@ import { POWER_CATEGORIES, ModelPolicySchema, } from '../../schemas/model-policy
18
18
  * `settings-set-models.ts`). They turn the flat Commander option bag into a
19
19
  * validated partial `ModelPolicy` (`ModelPolicySchema.parse`).
20
20
  */
21
- /** The three dispatch roles a policy can configure. */
22
- export const MODEL_ROLES = ['execution', 'validation', 'planning'];
21
+ /**
22
+ * The three dispatch roles a policy can configure. Derived from the
23
+ * single-source `PIPELINE_ROLES` (task #929); kept under the CLI-local names
24
+ * so existing call sites are undisturbed.
25
+ */
26
+ export const MODEL_ROLES = PIPELINE_ROLES;
23
27
  /**
24
28
  * Register the per-role model flags on a Commander command:
25
29
  * --<role>-<category> <model|auto> (byCategory route)
@@ -106,6 +110,46 @@ export function mergeModelPolicies(existing, partial) {
106
110
  }
107
111
  return ModelPolicySchema.parse(merged);
108
112
  }
113
+ /**
114
+ * Read `--json` off the GLOBAL program options for a set-models subcommand.
115
+ * The flag is registered on the root program, so subcommands must reach up via
116
+ * `optsWithGlobals()`. Shared by `project-set-models` / `settings-set-models`.
117
+ */
118
+ export function resolveSetModelsJsonMode(command) {
119
+ const globalOpts = command.parent?.optsWithGlobals() || {};
120
+ return Boolean(globalOpts['json']);
121
+ }
122
+ /**
123
+ * Shared set-models preamble for `project-set-models` / `settings-set-models`:
124
+ *
125
+ * 1. Assemble + validate a partial policy from the flat option bag
126
+ * (`buildModelPolicyFromOptions`). On a validation error, print it and
127
+ * set `process.exitCode = 1`, returning `{ stop: true }`.
128
+ * 2. When no model flags were supplied, warn and set `process.exitCode = 1`,
129
+ * returning `{ stop: true }`.
130
+ * 3. Otherwise return `{ stop: false, policy }`.
131
+ *
132
+ * Extracting this keeps the two commands' parse/validate/no-flags behavior and
133
+ * output byte-identical from one source.
134
+ */
135
+ export function parseSetModelsOptions(options) {
136
+ let policy;
137
+ try {
138
+ policy = buildModelPolicyFromOptions(options);
139
+ }
140
+ catch (error) {
141
+ const msg = error instanceof Error ? error.message : String(error);
142
+ console.error(colorError(`Invalid model policy: ${msg}`));
143
+ process.exitCode = 1;
144
+ return { stop: true };
145
+ }
146
+ if (policy === undefined) {
147
+ console.log(colorWarn('No model flags specified. Use --help to see available options.'));
148
+ process.exitCode = 1;
149
+ return { stop: true };
150
+ }
151
+ return { stop: false, policy };
152
+ }
109
153
  // ── `models list` ───────────────────────────────────────────
110
154
  const modelsListCommand = new Command('list')
111
155
  .description('List the available Claude model catalog (runtime-discovered)')
@@ -1,9 +1,9 @@
1
1
  import { Command } from 'commander';
2
2
  import { getProject, updateProject } from '../api/client.js';
3
- import { formatProjectDetail, colorError, colorWarn, colorSuccess } from '../output/formatters.js';
3
+ import { formatProjectDetail, colorError, colorSuccess } from '../output/formatters.js';
4
4
  import { handleError } from '../output/error-handler.js';
5
5
  import { jsonOutput } from '../output/json-output.js';
6
- import { addModelPolicyOptions, buildModelPolicyFromOptions, mergeModelPolicies, } from './models.js';
6
+ import { addModelPolicyOptions, mergeModelPolicies, parseSetModelsOptions, resolveSetModelsJsonMode, } from './models.js';
7
7
  /**
8
8
  * Configurable Task Models (Task 12) — `tasks project-set-models <id> [flags]`.
9
9
  *
@@ -24,21 +24,10 @@ export const projectSetModelsCommand = addModelPolicyOptions(new Command('projec
24
24
  process.exitCode = 1;
25
25
  return;
26
26
  }
27
- let modelPolicy;
28
- try {
29
- modelPolicy = buildModelPolicyFromOptions(options);
30
- }
31
- catch (error) {
32
- const msg = error instanceof Error ? error.message : String(error);
33
- console.error(colorError(`Invalid model policy: ${msg}`));
34
- process.exitCode = 1;
35
- return;
36
- }
37
- if (modelPolicy === undefined) {
38
- console.log(colorWarn('No model flags specified. Use --help to see available options.'));
39
- process.exitCode = 1;
27
+ const parsed = parseSetModelsOptions(options);
28
+ if (parsed.stop)
40
29
  return;
41
- }
30
+ const modelPolicy = parsed.policy;
42
31
  // Fetch-merge-write: the server replaces the column wholesale, so merge
43
32
  // the partial flag policy over the stored one here to keep incremental
44
33
  // invocations non-destructive.
@@ -47,9 +36,7 @@ export const projectSetModelsCommand = addModelPolicyOptions(new Command('projec
47
36
  model_policy: mergeModelPolicies(current.model_policy ?? null, modelPolicy),
48
37
  };
49
38
  const project = await updateProject(id, updates);
50
- const program = projectSetModelsCommand.parent;
51
- const globalOpts = program?.optsWithGlobals() || {};
52
- const isJsonMode = globalOpts['json'] || false;
39
+ const isJsonMode = resolveSetModelsJsonMode(projectSetModelsCommand);
53
40
  if (isJsonMode) {
54
41
  jsonOutput({ project }, { id: project.id });
55
42
  }
@@ -1,9 +1,9 @@
1
1
  import { Command } from 'commander';
2
2
  import { getModelPolicyDefault, setModelPolicyDefault } from '../api/client.js';
3
- import { colorError, colorWarn, colorSuccess } from '../output/formatters.js';
3
+ import { colorSuccess } from '../output/formatters.js';
4
4
  import { handleError } from '../output/error-handler.js';
5
5
  import { jsonOutput } from '../output/json-output.js';
6
- import { addModelPolicyOptions, buildModelPolicyFromOptions, mergeModelPolicies, } from './models.js';
6
+ import { addModelPolicyOptions, mergeModelPolicies, parseSetModelsOptions, resolveSetModelsJsonMode, } from './models.js';
7
7
  /**
8
8
  * Configurable Task Models (Task 12) — `tasks settings-set-models [flags]`.
9
9
  *
@@ -16,28 +16,15 @@ import { addModelPolicyOptions, buildModelPolicyFromOptions, mergeModelPolicies,
16
16
  */
17
17
  export const settingsSetModelsCommand = addModelPolicyOptions(new Command('settings-set-models').description('Set the database-wide default model policy (per-role / per-category routing)')).action(async (options) => {
18
18
  try {
19
- let modelPolicy;
20
- try {
21
- modelPolicy = buildModelPolicyFromOptions(options);
22
- }
23
- catch (error) {
24
- const msg = error instanceof Error ? error.message : String(error);
25
- console.error(colorError(`Invalid model policy: ${msg}`));
26
- process.exitCode = 1;
27
- return;
28
- }
29
- if (modelPolicy === undefined) {
30
- console.log(colorWarn('No model flags specified. Use --help to see available options.'));
31
- process.exitCode = 1;
19
+ const parsed = parseSetModelsOptions(options);
20
+ if (parsed.stop)
32
21
  return;
33
- }
22
+ const modelPolicy = parsed.policy;
34
23
  // Fetch-merge-write: keep incremental invocations non-destructive (the
35
24
  // server replaces the stored default wholesale).
36
25
  const current = await getModelPolicyDefault();
37
26
  const persisted = await setModelPolicyDefault(mergeModelPolicies(current, modelPolicy));
38
- const program = settingsSetModelsCommand.parent;
39
- const globalOpts = program?.optsWithGlobals() || {};
40
- const isJsonMode = globalOpts['json'] || false;
27
+ const isJsonMode = resolveSetModelsJsonMode(settingsSetModelsCommand);
41
28
  if (isJsonMode) {
42
29
  jsonOutput({ model_policy: persisted });
43
30
  }
@@ -333,6 +333,14 @@ export interface RunSetupInteractiveOptions extends RunSetupOptions {
333
333
  * here without standing up a full device-flow server.
334
334
  */
335
335
  deviceLogin?: typeof runDeviceLogin;
336
+ /**
337
+ * Injectable browser opener forwarded to the device-flow login (replaces the
338
+ * former no-browser env bandaid). Defaults to undefined, so production
339
+ * uses the real {@link openBrowser}. Tests that drive the REAL device flow
340
+ * (without stubbing {@link deviceLogin}) pass a stub here so the suite never
341
+ * spawns a real browser on a DISPLAY-set desktop.
342
+ */
343
+ opener?: (url: string) => boolean;
336
344
  /**
337
345
  * Injectable manual-PAT persistence seam (#809). Defaults to
338
346
  * {@link persistManualPat} (validate the PAT against `GET /api/v1/me`, then
@@ -720,6 +720,7 @@ export async function runRemoteOnboarding(options = {}) {
720
720
  clientId: process.env['OIDC_DEVICE_CLIENT_ID'] ?? 'wft-cli',
721
721
  hostname: os.hostname(),
722
722
  openBrowser: true,
723
+ ...(options.opener !== undefined && { opener: options.opener }),
723
724
  isJson: false,
724
725
  });
725
726
  deviceOk = result.ok;
@@ -15,6 +15,8 @@ export const updateCommand = new Command('update')
15
15
  .option('-a, --assignee <name>', 'New assignee')
16
16
  .option('--due <date>', 'New due date (ISO8601)')
17
17
  .option('--tags <tags>', 'New tags (comma-separated, replaces existing)')
18
+ .option('--blocked-by <ids>', 'Blocking task IDs (comma-separated). Only valid with --status blocked: ' +
19
+ 'adds the blocking dependency edge(s) and sets the status atomically')
18
20
  .action(async (idStr, options) => {
19
21
  try {
20
22
  // Parse and validate task ID
@@ -59,6 +61,24 @@ export const updateCommand = new Command('update')
59
61
  if (options.tags !== undefined) {
60
62
  updates.tags = options.tags.split(',').map((tag) => tag.trim());
61
63
  }
64
+ if (options.blockedBy !== undefined) {
65
+ // Task #1004: atomic block-with-dependency. Mirror the server's narrow
66
+ // semantics client-side so the misuse fails before the wire.
67
+ if (options.status !== 'blocked') {
68
+ console.error(colorError('--blocked-by is only valid together with --status blocked ' +
69
+ '(the edge-add and the status flip commit atomically). ' +
70
+ 'To add edges without blocking, use `tasks dep-add` instead.'));
71
+ process.exitCode = 1;
72
+ return;
73
+ }
74
+ const blockedBy = options.blockedBy.split(',').map((s) => parseInt(s.trim(), 10));
75
+ if (blockedBy.length === 0 || blockedBy.some((n) => isNaN(n) || n <= 0)) {
76
+ console.error(colorError('Invalid --blocked-by: must be comma-separated positive task IDs'));
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+ updates.blocked_by = blockedBy;
81
+ }
62
82
  // Check if any updates were specified
63
83
  if (Object.keys(updates).length === 0) {
64
84
  console.log(colorWarn('No updates specified. Use --help to see available options.'));
@@ -1,4 +1,4 @@
1
- import { TaskEvent, ProjectEvent } from './types.js';
1
+ import { TaskEvent, ProjectEvent, ClaimReleasedEvent } from './types.js';
2
2
  /**
3
3
  * Options for {@link EventBus.subscribe}.
4
4
  *
@@ -90,6 +90,7 @@ type AppEvents = {
90
90
  'task.deleted': TaskEvent;
91
91
  'task.status_changed': TaskEvent;
92
92
  'task.claimed': TaskEvent;
93
+ 'task.claim_released': ClaimReleasedEvent;
93
94
  'project.created': ProjectEvent;
94
95
  'project.updated': ProjectEvent;
95
96
  'project.deleted': ProjectEvent;
@@ -178,6 +178,7 @@ export class EventBus {
178
178
  this.emitter.listenerCount('task.deleted') +
179
179
  this.emitter.listenerCount('task.status_changed') +
180
180
  this.emitter.listenerCount('task.claimed') +
181
+ this.emitter.listenerCount('task.claim_released') +
181
182
  this.emitter.listenerCount('project.created') +
182
183
  this.emitter.listenerCount('project.updated') +
183
184
  this.emitter.listenerCount('project.deleted'),
@@ -34,7 +34,7 @@ export declare class SSEManager {
34
34
  private heartbeatInterval?;
35
35
  private createdAt;
36
36
  private totalEventsSent;
37
- constructor(maxBufferSize?: number, bufferTtlMs?: number, // 5 minutes
37
+ constructor(maxBufferSize?: number, bufferTtlMs?: number, // 10 minutes — keep >= maxConnectionAgeMs
38
38
  heartbeatIntervalMs?: number, // 30 seconds
39
39
  maxConnectionAgeMs?: number, // 10 minutes
40
40
  maxConnectionsPerKey?: number, maxConnectionsPerIp?: number, maxConnections?: number);
@@ -56,13 +56,42 @@ export declare class SSEManager {
56
56
  apiKeyFingerprint: string;
57
57
  ip: string;
58
58
  }): void;
59
+ /**
60
+ * Remove a connection from the broadcast map WITHOUT touching the socket.
61
+ *
62
+ * task #1001: this is only safe when the peer socket is already gone — it
63
+ * is the cleanup callback for the raw `close`/`error` events registered in
64
+ * `addConnection`. Every server-initiated eviction (age-out, send failure,
65
+ * heartbeat failure, shutdown) MUST go through `closeConnection` instead,
66
+ * which ends the stream so the client observes a FIN and reconnects.
67
+ */
59
68
  removeConnection(connectionId: string): void;
69
+ /**
70
+ * task #1001: server-initiated eviction. Removes the connection from the
71
+ * broadcast map AND closes the underlying stream so the client sees the
72
+ * connection end and its reconnect logic fires (EventSource-style clients
73
+ * reconnect with `Last-Event-Id` and resume via `replayEvents`).
74
+ *
75
+ * Before this fix, the age-out path in the heartbeat sweep called
76
+ * `removeConnection` only — the map entry vanished but the TCP socket
77
+ * stayed open with no events, no pings and no FIN, so clients went
78
+ * silently deaf after maxConnectionAgeMs (reproduced 3x in the Tiny
79
+ * Worlds orchestration pilot, 2026-06-10).
80
+ */
81
+ private closeConnection;
60
82
  broadcast(event: EventPayload<unknown>): void;
61
83
  private matchesFilters;
62
84
  private sendEvent;
63
85
  private replayEvents;
64
86
  private pruneEventBuffer;
65
87
  private startHeartbeat;
88
+ /**
89
+ * One heartbeat tick: ping every live connection and evict connections
90
+ * past maxConnectionAgeMs. Extracted from the setInterval callback so
91
+ * integration tests can drive a sweep deterministically without faking
92
+ * 30s of wall-clock time against a real listening socket (task #1001).
93
+ */
94
+ private heartbeatSweep;
66
95
  shutdown(): void;
67
96
  /**
68
97
  * Check if SSE manager is healthy (has active heartbeat)
@@ -20,7 +20,19 @@ export class SSEManager {
20
20
  heartbeatInterval;
21
21
  createdAt = Date.now();
22
22
  totalEventsSent = 0;
23
- constructor(maxBufferSize = 100, bufferTtlMs = 5 * 60 * 1000, // 5 minutes
23
+ constructor(maxBufferSize = 100,
24
+ // task #1001: bufferTtlMs MUST be >= maxConnectionAgeMs. When a
25
+ // connection is aged out (closeConnection → stream FIN), the client
26
+ // reconnects with `Last-Event-Id` and expects the buffer to still hold
27
+ // every event it could have missed during its previous connection's
28
+ // lifetime. The old default (5 min) was SMALLER than the 10-min age
29
+ // window, so a quiet client whose last event predated the TTL hit a
30
+ // replay gap on every forced reconnect. The defaults are the production
31
+ // values (server.ts passes `undefined` for both), so aligning here
32
+ // aligns prod. Residual gap risk: maxBufferSize (100 events) can still
33
+ // truncate under high event rate regardless of TTL — that case is
34
+ // covered by the explicit `replay-gap` control event (task #206).
35
+ bufferTtlMs = 10 * 60 * 1000, // 10 minutes — keep >= maxConnectionAgeMs
24
36
  heartbeatIntervalMs = 30000, // 30 seconds
25
37
  maxConnectionAgeMs = 10 * 60 * 1000, // 10 minutes
26
38
  // task #185: per-key, per-IP and global concurrent connection caps.
@@ -87,9 +99,53 @@ export class SSEManager {
87
99
  reply.raw.on('close', () => this.removeConnection(connectionId));
88
100
  reply.raw.on('error', () => this.removeConnection(connectionId));
89
101
  }
102
+ /**
103
+ * Remove a connection from the broadcast map WITHOUT touching the socket.
104
+ *
105
+ * task #1001: this is only safe when the peer socket is already gone — it
106
+ * is the cleanup callback for the raw `close`/`error` events registered in
107
+ * `addConnection`. Every server-initiated eviction (age-out, send failure,
108
+ * heartbeat failure, shutdown) MUST go through `closeConnection` instead,
109
+ * which ends the stream so the client observes a FIN and reconnects.
110
+ */
90
111
  removeConnection(connectionId) {
91
112
  this.connections.delete(connectionId);
92
113
  }
114
+ /**
115
+ * task #1001: server-initiated eviction. Removes the connection from the
116
+ * broadcast map AND closes the underlying stream so the client sees the
117
+ * connection end and its reconnect logic fires (EventSource-style clients
118
+ * reconnect with `Last-Event-Id` and resume via `replayEvents`).
119
+ *
120
+ * Before this fix, the age-out path in the heartbeat sweep called
121
+ * `removeConnection` only — the map entry vanished but the TCP socket
122
+ * stayed open with no events, no pings and no FIN, so clients went
123
+ * silently deaf after maxConnectionAgeMs (reproduced 3x in the Tiny
124
+ * Worlds orchestration pilot, 2026-06-10).
125
+ */
126
+ closeConnection(connectionId) {
127
+ const conn = this.connections.get(connectionId);
128
+ this.connections.delete(connectionId);
129
+ if (!conn)
130
+ return;
131
+ try {
132
+ // Prefer the SSE plugin's close (runs its cleanup + ends the raw
133
+ // response). `reply.sse` can be absent at runtime (the events route
134
+ // guards on it), and unit-test mocks may omit either surface — fall
135
+ // back to ending the raw response directly.
136
+ if (conn.reply.sse && typeof conn.reply.sse.close === 'function') {
137
+ conn.reply.sse.close();
138
+ }
139
+ else if (typeof conn.reply.raw?.end === 'function') {
140
+ conn.reply.raw.end();
141
+ }
142
+ }
143
+ catch {
144
+ // The peer socket may already be destroyed (close racing a network
145
+ // error). Safe to swallow: the map entry is gone and the raw
146
+ // `close`/`error` handlers have done (or will do) their cleanup.
147
+ }
148
+ }
93
149
  broadcast(event) {
94
150
  const eventId = this.nextEventId++;
95
151
  // Add to buffer for Last-Event-ID replay
@@ -131,8 +187,12 @@ export class SSEManager {
131
187
  this.totalEventsSent++;
132
188
  })
133
189
  .catch(() => {
134
- // Connection likely closed, remove it
135
- this.removeConnection(conn.id);
190
+ // Send failed the peer socket usually errored already (the raw
191
+ // `error` handler runs cleanup), but close defensively so a failure
192
+ // that did NOT originate from the socket still ends the stream
193
+ // instead of leaving the peer attached to a deregistered connection.
194
+ // `closeConnection` is idempotent vs. an already-dead socket.
195
+ this.closeConnection(conn.id);
136
196
  });
137
197
  }
138
198
  replayEvents(connectionId, fromEventId) {
@@ -163,7 +223,8 @@ export class SSEManager {
163
223
  }),
164
224
  })
165
225
  .catch(() => {
166
- this.removeConnection(connectionId);
226
+ // Same rationale as sendEvent: close, don't just deregister.
227
+ this.closeConnection(connectionId);
167
228
  });
168
229
  }
169
230
  }
@@ -184,26 +245,47 @@ export class SSEManager {
184
245
  this.eventBuffer = this.eventBuffer.filter((e) => new Date(e.event.timestamp).getTime() > cutoff);
185
246
  }
186
247
  startHeartbeat() {
187
- this.heartbeatInterval = setInterval(() => {
188
- const now = Date.now();
189
- for (const conn of this.connections.values()) {
190
- // Send heartbeat ping (fire-and-forget)
191
- conn.reply.sse.send({ event: 'ping', data: '' }).catch(() => {
192
- // Connection closed, remove it
193
- this.removeConnection(conn.id);
194
- });
195
- // Enforce max connection age
196
- const age = now - conn.createdAt.getTime();
197
- if (age > this.maxConnectionAgeMs) {
198
- this.removeConnection(conn.id);
199
- }
248
+ this.heartbeatInterval = setInterval(() => this.heartbeatSweep(), this.heartbeatIntervalMs);
249
+ }
250
+ /**
251
+ * One heartbeat tick: ping every live connection and evict connections
252
+ * past maxConnectionAgeMs. Extracted from the setInterval callback so
253
+ * integration tests can drive a sweep deterministically without faking
254
+ * 30s of wall-clock time against a real listening socket (task #1001).
255
+ */
256
+ heartbeatSweep() {
257
+ const now = Date.now();
258
+ for (const conn of this.connections.values()) {
259
+ // Enforce max connection age FIRST. task #1001: this must CLOSE the
260
+ // stream, not just drop the map entry — otherwise the client keeps an
261
+ // open TCP connection receiving no events, no pings and no FIN, and
262
+ // its reconnect logic never fires. Closing makes the client reconnect
263
+ // with `Last-Event-Id` and resume via the replay buffer (whose TTL is
264
+ // aligned to this window — see the constructor note on bufferTtlMs).
265
+ const age = now - conn.createdAt.getTime();
266
+ if (age > this.maxConnectionAgeMs) {
267
+ this.closeConnection(conn.id);
268
+ continue; // No point pinging a stream we just ended.
200
269
  }
201
- }, this.heartbeatIntervalMs);
270
+ // Send heartbeat ping (fire-and-forget)
271
+ conn.reply.sse.send({ event: 'ping', data: '' }).catch(() => {
272
+ // Ping failed — close defensively (idempotent vs. dead socket),
273
+ // same rationale as sendEvent.
274
+ this.closeConnection(conn.id);
275
+ });
276
+ }
202
277
  }
203
278
  shutdown() {
204
279
  if (this.heartbeatInterval) {
205
280
  clearInterval(this.heartbeatInterval);
206
281
  }
282
+ // task #1001: close every stream instead of just clearing the map —
283
+ // clearing alone left peers attached to a server that would never send
284
+ // them another byte. (In practice Fastify's own close tears sockets
285
+ // down too; this makes the manager correct independent of that.)
286
+ for (const connectionId of [...this.connections.keys()]) {
287
+ this.closeConnection(connectionId);
288
+ }
207
289
  this.connections.clear();
208
290
  this.eventBuffer = [];
209
291
  }
@@ -3,7 +3,7 @@ import { Task, Project, TaskStatus } from '../types/task.js';
3
3
  * Event types for task lifecycle
4
4
  * Note: task.claimed is defined for type safety but emission deferred to Phase 15 (atomic claim endpoint)
5
5
  */
6
- export type TaskEventType = 'task.created' | 'task.updated' | 'task.deleted' | 'task.status_changed' | 'task.claimed';
6
+ export type TaskEventType = 'task.created' | 'task.updated' | 'task.deleted' | 'task.status_changed' | 'task.claimed' | 'task.claim_released';
7
7
  /**
8
8
  * Event types for project lifecycle
9
9
  */
@@ -16,7 +16,7 @@ export type ProjectEventType = 'project.created' | 'project.updated' | 'project.
16
16
  * Keep this in sync with the union types above and with the eventBus.subscribe
17
17
  * calls in `src/api/server.ts`.
18
18
  */
19
- export declare const ALLOWED_EVENT_TYPES: readonly ["task.created", "task.updated", "task.deleted", "task.status_changed", "task.claimed", "project.created", "project.updated", "project.deleted"];
19
+ export declare const ALLOWED_EVENT_TYPES: readonly ["task.created", "task.updated", "task.deleted", "task.status_changed", "task.claimed", "task.claim_released", "project.created", "project.updated", "project.deleted"];
20
20
  export type AllowedEventType = (typeof ALLOWED_EVENT_TYPES)[number];
21
21
  export declare function isAllowedEventType(value: string): value is AllowedEventType;
22
22
  /**
@@ -55,6 +55,23 @@ export interface EventPayload<T> {
55
55
  export type TaskEvent = EventPayload<Task & {
56
56
  tags: string[];
57
57
  }>;
58
+ /**
59
+ * Task #1003: payload for `task.claim_released` — emitted by the
60
+ * ClaimReleaseService TTL sweep when a stale `in_progress` claim is
61
+ * auto-released back to `open`. Carries the full (post-release) task plus
62
+ * the claim's forensic trail so the former holder (and wft-router rules)
63
+ * can react: who held it, when the expired claim was taken, and when the
64
+ * sweep released it.
65
+ */
66
+ export type ClaimReleasedEvent = EventPayload<Task & {
67
+ tags: string[];
68
+ /** Assignee that held the claim before the TTL sweep released it. */
69
+ previous_assignee: string;
70
+ /** The `claimed_at` timestamp that exceeded the TTL (pre-release value). */
71
+ expired_claimed_at: string;
72
+ /** When the sweep released the claim (ISO 8601). */
73
+ released_at: string;
74
+ }>;
58
75
  /**
59
76
  * Project event
60
77
  */
@@ -12,6 +12,7 @@ export const ALLOWED_EVENT_TYPES = [
12
12
  'task.deleted',
13
13
  'task.status_changed',
14
14
  'task.claimed',
15
+ 'task.claim_released',
15
16
  'project.created',
16
17
  'project.updated',
17
18
  'project.deleted',
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ import { DependencyGraphService } from './services/dependency-graph.service.js';
9
9
  import { WorkflowEngine } from './services/workflow-engine.js';
10
10
  import { type SettingsService } from './services/settings.service.js';
11
11
  import { type ModelCatalogService } from './services/model-catalog.service.js';
12
+ import { type ModelPolicyService } from './services/model-policy.service.js';
12
13
  import { type OidcConfig } from './services/oidc-client.js';
13
14
  import type Database from './db/driver.js';
14
15
  /**
@@ -70,6 +71,15 @@ export interface App {
70
71
  * is unreachable.
71
72
  */
72
73
  modelCatalogService: ModelCatalogService;
74
+ /**
75
+ * Configurable Task Models (task #931): the jobSize→category bijection +
76
+ * two-layer per-slot model resolver. Constructed ONCE here (like
77
+ * settingsService / modelCatalogService) and consumed by BOTH transports —
78
+ * the REST route (`GET /projects/:id/resolve-model` via the Fastify
79
+ * decoration) and the stdio MCP `resolve_model` tool — instead of each
80
+ * hand-wiring an identical dep bundle over fresh repositories.
81
+ */
82
+ modelPolicyService: ModelPolicyService;
73
83
  /**
74
84
  * Identity-foundation repositories (Phase 27) decorated onto the Fastify
75
85
  * instance by `createServer` so the Phase 28 auth chain at