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.
- package/CHANGELOG.md +57 -3
- package/README.md +2 -1
- package/SECURITY.md +2 -2
- package/dist/api/api-response.d.ts +4 -0
- package/dist/api/routes/models/index.d.ts +16 -0
- package/dist/api/routes/models/index.js +1 -7
- package/dist/api/routes/projects/resolve-model.js +17 -8
- package/dist/api/routes/projects/wsjf.js +4 -0
- package/dist/api/routes/settings/model-policy.js +8 -4
- package/dist/api/routes/tasks/schemas.d.ts +8 -0
- package/dist/api/routes/tasks/schemas.js +10 -0
- package/dist/api/server.d.ts +1 -1
- package/dist/api/server.js +6 -19
- package/dist/cli/api/client.d.ts +7 -7
- package/dist/cli/api/types.d.ts +6 -0
- package/dist/cli/auth/browser-open.js +0 -11
- package/dist/cli/auth/credentials.d.ts +19 -0
- package/dist/cli/auth/credentials.js +30 -0
- package/dist/cli/commands/comment-add.js +8 -1
- package/dist/cli/commands/create.js +9 -1
- package/dist/cli/commands/list.js +30 -7
- package/dist/cli/commands/login.d.ts +10 -0
- package/dist/cli/commands/login.js +2 -1
- package/dist/cli/commands/models.d.ts +38 -3
- package/dist/cli/commands/models.js +48 -4
- package/dist/cli/commands/project-set-models.js +6 -19
- package/dist/cli/commands/settings-set-models.js +6 -19
- package/dist/cli/commands/setup.d.ts +8 -0
- package/dist/cli/commands/setup.js +1 -0
- package/dist/cli/commands/update.js +20 -0
- package/dist/events/event-bus.d.ts +2 -1
- package/dist/events/event-bus.js +1 -0
- package/dist/events/sse-manager.d.ts +30 -1
- package/dist/events/sse-manager.js +100 -18
- package/dist/events/types.d.ts +19 -2
- package/dist/events/types.js +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +36 -1
- package/dist/mcp/index.js +5 -24
- package/dist/mcp/lib/model-tool-definitions.d.ts +122 -0
- package/dist/mcp/lib/model-tool-definitions.js +112 -0
- package/dist/mcp/remote/register-tools.js +14 -73
- package/dist/mcp/remote/rest-client.d.ts +4 -10
- package/dist/mcp/resources/events.js +2 -1
- package/dist/mcp/tools/health-tools.d.ts +14 -0
- package/dist/mcp/tools/health-tools.js +52 -1
- package/dist/mcp/tools/model-tools.d.ts +4 -3
- package/dist/mcp/tools/model-tools.js +13 -82
- package/dist/mcp/tools/task-tools.js +5 -1
- package/dist/repositories/interfaces.d.ts +39 -0
- package/dist/repositories/project-charter-history.repository.js +2 -12
- package/dist/repositories/project.repository.js +16 -48
- package/dist/repositories/task.repository.d.ts +48 -0
- package/dist/repositories/task.repository.js +90 -18
- package/dist/repositories/wsjf-history.repository.js +6 -16
- package/dist/schemas/model-catalog.schema.d.ts +22 -0
- package/dist/schemas/model-catalog.schema.js +21 -0
- package/dist/schemas/model-policy.schema.d.ts +14 -0
- package/dist/schemas/model-policy.schema.js +11 -0
- package/dist/schemas/task.schema.d.ts +13 -1
- package/dist/schemas/task.schema.js +11 -0
- package/dist/services/claim-release.service.d.ts +11 -0
- package/dist/services/claim-release.service.js +31 -2
- package/dist/services/job-size-backfill.d.ts +43 -0
- package/dist/services/job-size-backfill.js +81 -0
- package/dist/services/model-catalog.service.d.ts +28 -8
- package/dist/services/model-catalog.service.js +16 -4
- package/dist/services/model-policy.service.d.ts +87 -9
- package/dist/services/model-policy.service.js +96 -4
- package/dist/services/settings.service.d.ts +3 -2
- package/dist/services/settings.service.js +30 -10
- package/dist/services/task.service.d.ts +87 -3
- package/dist/services/task.service.js +351 -16
- package/dist/services/wsjf-health.service.d.ts +7 -1
- package/dist/services/wsjf-health.service.js +27 -0
- package/dist/services/wsjf.service.d.ts +19 -0
- package/dist/services/wsjf.service.js +33 -0
- package/dist/skills/tasks/blocked.md +2 -1
- package/dist/skills/tasks/decompose.md +30 -4
- package/dist/skills/tasks/loop-dag.md +3 -1
- package/dist/skills/tasks/loop.md +1 -1
- package/dist/slack/notifier.js +1 -0
- package/dist/slack/task-formatter.js +1 -0
- package/dist/types/task.d.ts +12 -17
- package/dist/types/wsjf.d.ts +1 -1
- package/dist/types/wsjf.js +2 -0
- package/dist/utils/parse-json-column.d.ts +25 -0
- package/dist/utils/parse-json-column.js +39 -0
- package/docs/API.md +23 -10
- package/docs/ARCHITECTURE.md +16 -14
- package/docs/CLI.md +11 -12
- package/docs/INTERFACES.md +1 -1
- package/docs/MCP.md +105 -4
- package/docs/SLACK.md +1 -0
- package/package.json +1 -1
- package/packages/wft-router/README.md +12 -0
- package/packages/wft-router/dist/bin/wft-router.d.ts +10 -0
- package/packages/wft-router/dist/bin/wft-router.js +18 -0
- package/packages/wft-router/dist/config/event-types.d.ts +1 -1
- package/packages/wft-router/dist/config/event-types.js +1 -0
- package/packages/wft-router/dist/config/triggers-schema.d.ts +9 -0
- package/packages/wft-router/dist/config/triggers-schema.js +10 -0
- package/packages/wft-router/dist/daemon.d.ts +36 -0
- package/packages/wft-router/dist/daemon.js +101 -2
- package/packages/wft-router/dist/dispatch/startup-sweep.d.ts +96 -0
- package/packages/wft-router/dist/dispatch/startup-sweep.js +163 -0
- package/packages/wft-router/dist/index.d.ts +2 -2
- package/packages/wft-router/dist/index.js +1 -1
- package/packages/wft-router/dist/metrics.d.ts +64 -4
- package/packages/wft-router/dist/metrics.js +0 -0
- package/packages/wft-router/dist/sse/client.d.ts +19 -0
- package/packages/wft-router/dist/sse/client.js +57 -3
- package/packages/wft-router/dist/sse/index.d.ts +1 -1
- 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 =
|
|
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
|
-
/**
|
|
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 =
|
|
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
|
-
/**
|
|
22
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
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;
|
package/dist/events/event-bus.js
CHANGED
|
@@ -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, //
|
|
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,
|
|
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
|
-
//
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/events/types.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/events/types.js
CHANGED
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
|