dsh-taskboard 0.5.0 → 0.5.2
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/README.md +25 -1
- package/lib/client.js +242 -174
- package/lib/host/execution.js +80 -33
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +49 -5
- package/lib/host/git.js.map +1 -1
- package/lib/host/routes.js +180 -109
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduler.js +50 -28
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/sdk.js +7 -2
- package/lib/host/sdk.js.map +1 -1
- package/lib/host/store.js +41 -8
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +10 -3
- package/lib/host/templates.js.map +1 -1
- package/lib/host/tools.js +124 -93
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +3 -1
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +23 -2
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +3 -2
- package/src/client/api.ts +19 -9
- package/src/client/board/ImportModal.tsx +1 -1
- package/src/client/board/TaskBoard.tsx +7 -38
- package/src/client/board/TaskCard.tsx +3 -5
- package/src/client/board/TaskDetail.tsx +30 -21
- package/src/client/board/TaskFormModal.tsx +30 -23
- package/src/client/board/format.ts +26 -0
- package/src/client/board/labels.ts +44 -0
- package/src/client/board-mount.tsx +9 -6
- package/src/client/controller.ts +60 -13
- package/src/client/index.ts +7 -5
- package/src/client/sidebar-entry.ts +16 -5
- package/src/client/styles.ts +5 -3
- package/src/host/execution.ts +90 -16
- package/src/host/git.ts +39 -10
- package/src/host/routes.ts +227 -126
- package/src/host/scheduler.ts +62 -36
- package/src/host/sdk.ts +12 -1
- package/src/host/store.ts +53 -7
- package/src/host/templates.ts +12 -3
- package/src/host/tools.ts +180 -123
- package/src/index.ts +10 -1
- package/src/shared/api.ts +1 -1
- package/src/shared/protocol.ts +35 -1
- package/src/shared/version.ts +1 -1
- package/src/client/board/NewTaskModal.tsx +0 -8
package/lib/host/sdk.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sdk.js","names":[],"sources":["../../src/host/sdk.ts"],"sourcesContent":["/**\n * Self-contained replacements for the three @deepseek-ai runtime imports the\n * host half used to take from npm-mirror SDK packages (dsh-home-paths,\n * dsh-llm/brand, dsh-tools' defineTool).\n *\n * Why: a published copy must never resolve `@deepseek-ai/dsh-tools` from the\n * profile's node_modules — an npm-mirror dsh-tools there shadows the\n * CLI-internal build for the WHOLE base layer, and the agent loop's private\n * scheduler symbol then misses (`Cannot read properties of undefined\n * (reading 'prepare')` on every tool call). Everything here is a pure,\n * structure-compatible reimplementation of the exact behavior we relied on:\n *\n * - `dshHomePath` mirrors `join(resolve(env.DSH_HOME ?? ~/.dsh), ...segments)`;\n * - `MessageId` is the identity brand the SDK applies at runtime;\n * - `defineTool` compiles our author-facing parameter specs into the same\n * raw JSON-Schema subset the registry expects (object/properties/required/\n * additionalProperties/scalars; the `json` node compiles to an\n * annotation-only schema) and pre-validates model arguments the same way.\n *\n * @module dsh-taskboard/host/sdk\n */\nimport { homedir } from 'node:os'\nimport { join, resolve } from 'node:path'\n\n/** The ledger file's parent: the DSH user home (DSH_HOME overrides). */\nexport function dshHomePath(...segments: string[]): string {\n const override = process.env.DSH_HOME\n const home = resolve(override !== undefined && override.length > 0 ? override : join(homedir(), '.dsh'))\n return join(home, ...segments)\n}\n\n/** Identity brand — runtime no-op, exactly like the SDK's MessageId(). */\nexport function MessageId(id: string): string {\n return id\n}\n\n/** Author-facing scalar spec. */\ninterface ScalarSpec {\n readonly type: 'string' | 'number' | 'integer' | 'boolean' | 'null'\n readonly description?: string\n readonly enum?: readonly unknown[]\n readonly const?: unknown\n}\n\n/** Author-facing object spec (additionalProperties is mandatory). */\ninterface ObjectSpec {\n readonly type: 'object'\n readonly additionalProperties: boolean\n readonly description?: string\n readonly properties?: Readonly<Record<string, ValueSpec>>\n}\n\n/** Author-facing value spec. */\ntype ValueSpec = ScalarSpec | ObjectSpec | { readonly type: 'json' } | { readonly type: 'array'; readonly items?: ValueSpec; readonly description?: string }\n\n/** Author-facing parameter entry (a value spec plus top-level required). */\ntype ParameterSpec = ValueSpec & { readonly required?: boolean }\n\n/** Raw JSON-Schema subset node. */\ntype RawSchema = Record<string, unknown>\n\n/** Compile one value spec to the raw subset (json → annotation-only). */\nfunction compileValue(spec: ValueSpec): RawSchema {\n const node: RawSchema = {}\n const description = (spec as { description?: string }).description\n if (typeof description === 'string' && description.length > 0) node.description = description\n const type = (spec as { type?: string }).type\n if (type === undefined || type === 'json') return node\n if (type === 'object') {\n const objectSpec = spec as ObjectSpec\n node.type = 'object'\n node.additionalProperties = objectSpec.additionalProperties\n if (objectSpec.properties !== undefined) node.properties = compilePropertyMap(objectSpec.properties).properties\n return node\n }\n if (type === 'array') {\n node.type = 'array'\n const items = (spec as { items?: ValueSpec }).items\n if (items !== undefined) node.items = compileValue(items)\n return node\n }\n node.type = type\n const enumValues = (spec as ScalarSpec).enum\n if (enumValues !== undefined) node.enum = [...enumValues]\n const constValue = (spec as ScalarSpec).const\n if (constValue !== undefined) node.const = constValue\n return node\n}\n\n/** Compile a property map: properties + collected required list. */\nfunction compilePropertyMap(spec: Readonly<Record<string, ParameterSpec>>): { properties: Record<string, RawSchema>; required?: string[] } {\n const properties: Record<string, RawSchema> = {}\n const required: string[] = []\n for (const [name, entry] of Object.entries(spec)) {\n const { required: isRequired, ...valueSpec } = entry as ParameterSpec & Record<string, unknown>\n properties[name] = compileValue(valueSpec as ValueSpec)\n if (isRequired === true) required.push(name)\n }\n return required.length > 0 ? { properties, required } : { properties }\n}\n\n/** Does a JS value match a raw-subset scalar type? */\nfunction matchesScalarType(value: unknown, type: string): boolean {\n switch (type) {\n case 'string': return typeof value === 'string'\n case 'number': return typeof value === 'number'\n case 'integer': return typeof value === 'number' && Number.isInteger(value)\n case 'boolean': return typeof value === 'boolean'\n case 'null': return value === null\n default: return true\n }\n}\n\n/** Validate a value against the compiled subset; returns path-qualified violations. */\nfunction validateValue(schema: RawSchema, value: unknown, path: string): string[] {\n if (typeof schema.type !== 'string' || schema.type.length === 0) return []\n if (schema.type === 'object') {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return [`${path} must be an object`]\n const violations: string[] = []\n const present = value as Record<string, unknown>\n for (const key of (schema.required as string[] | undefined) ?? []) {\n if (!(key in present)) violations.push(`${path}.${key} is required`)\n }\n if (schema.additionalProperties === false) {\n const known = new Set(Object.keys((schema.properties as Record<string, RawSchema> | undefined) ?? {}))\n for (const key of Object.keys(present)) {\n if (!known.has(key)) violations.push(`${path}.${key} is not a declared property`)\n }\n }\n for (const [key, child] of Object.entries((schema.properties as Record<string, RawSchema> | undefined) ?? {})) {\n if (key in present) violations.push(...validateValue(child, present[key], `${path}.${key}`))\n }\n return violations\n }\n if (schema.type === 'array') {\n if (!Array.isArray(value)) return [`${path} must be an array`]\n const violations: string[] = []\n const items = schema.items as RawSchema | undefined\n if (items !== undefined) {\n value.forEach((item, index) => { violations.push(...validateValue(items, item, `${path}[${index}]`)) })\n }\n return violations\n }\n return matchesScalarType(value, schema.type) ? [] : [`${path} must be ${schema.type}`]\n}\n\n/** Options shape we consume (a structural subset of the SDK's defineTool). */\nexport interface DefineToolOptions<A, V> {\n readonly name: string\n readonly description: string\n readonly parameters: Readonly<Record<string, ParameterSpec>>\n readonly output: {\n readonly schema: { readonly type: 'json' }\n render(args: A, value: V): Array<{ type: 'text'; text: string }>\n }\n execute(args: A, exec: unknown): Promise<V>\n}\n\n/** A registry-ready tool definition (structure-compatible with the SDK's). */\nexport interface ToolDefinition<A = unknown, V = unknown> {\n readonly name: string\n readonly description: string\n readonly parameters: RawSchema\n readonly output: {\n readonly schema: RawSchema\n render(args: A, value: V): Array<{ type: 'text'; text: string }>\n }\n execute(args: A, exec: unknown): Promise<V>\n}\n\n/**\n * Define a first-party tool: compile the parameter spec, pre-validate\n * arguments (message format matches the SDK's ToolArgsError), and pass\n * through the execution.\n */\nexport function defineTool<A extends Record<string, unknown>, V>(options: DefineToolOptions<A, V>): ToolDefinition<A, V> {\n const compiled = compilePropertyMap(options.parameters as Readonly<Record<string, ParameterSpec>>)\n const parameters: RawSchema = { type: 'object', properties: compiled.properties }\n if (compiled.required !== undefined) parameters.required = compiled.required\n const userExecute = options.execute\n return {\n name: options.name,\n description: options.description,\n parameters,\n output: {\n // The SDK compiles the `json` node to an annotation-only schema.\n schema: {},\n render(args, value) {\n return options.output.render(args, value)\n },\n },\n async execute(args, exec) {\n const violations = validateValue(parameters, args, 'arguments')\n if (violations.length > 0) {\n throw new Error(`Error: invalid arguments: ${violations.join('; ')}`)\n }\n return userExecute(args, exec)\n },\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"sdk.js","names":[],"sources":["../../src/host/sdk.ts"],"sourcesContent":["/**\n * Self-contained replacements for the three @deepseek-ai runtime imports the\n * host half used to take from npm-mirror SDK packages (dsh-home-paths,\n * dsh-llm/brand, dsh-tools' defineTool).\n *\n * Why: a published copy must never resolve `@deepseek-ai/dsh-tools` from the\n * profile's node_modules — an npm-mirror dsh-tools there shadows the\n * CLI-internal build for the WHOLE base layer, and the agent loop's private\n * scheduler symbol then misses (`Cannot read properties of undefined\n * (reading 'prepare')` on every tool call). Everything here is a pure,\n * structure-compatible reimplementation of the exact behavior we relied on:\n *\n * - `dshHomePath` mirrors `join(resolve(env.DSH_HOME ?? ~/.dsh), ...segments)`;\n * - `MessageId` is the identity brand the SDK applies at runtime;\n * - `defineTool` compiles our author-facing parameter specs into the same\n * raw JSON-Schema subset the registry expects (object/properties/required/\n * additionalProperties/scalars; the `json` node compiles to an\n * annotation-only schema) and pre-validates model arguments the same way.\n *\n * @module dsh-taskboard/host/sdk\n */\nimport { homedir } from 'node:os'\nimport { join, resolve } from 'node:path'\n\n/** The ledger file's parent: the DSH user home (DSH_HOME overrides). */\nexport function dshHomePath(...segments: string[]): string {\n const override = process.env.DSH_HOME\n const home = resolve(override !== undefined && override.length > 0 ? override : join(homedir(), '.dsh'))\n return join(home, ...segments)\n}\n\n/** Identity brand — runtime no-op, exactly like the SDK's MessageId(). */\nexport function MessageId(id: string): string {\n return id\n}\n\n/** Author-facing scalar spec. */\ninterface ScalarSpec {\n readonly type: 'string' | 'number' | 'integer' | 'boolean' | 'null'\n readonly description?: string\n readonly enum?: readonly unknown[]\n readonly const?: unknown\n}\n\n/** Author-facing object spec (additionalProperties is mandatory). */\ninterface ObjectSpec {\n readonly type: 'object'\n readonly additionalProperties: boolean\n readonly description?: string\n readonly properties?: Readonly<Record<string, ValueSpec>>\n}\n\n/** Author-facing value spec. */\ntype ValueSpec = ScalarSpec | ObjectSpec | { readonly type: 'json' } | { readonly type: 'array'; readonly items?: ValueSpec; readonly description?: string }\n\n/** Author-facing parameter entry (a value spec plus top-level required). */\ntype ParameterSpec = ValueSpec & { readonly required?: boolean }\n\n/** Raw JSON-Schema subset node. */\ntype RawSchema = Record<string, unknown>\n\n/** Compile one value spec to the raw subset (json → annotation-only). */\nfunction compileValue(spec: ValueSpec): RawSchema {\n const node: RawSchema = {}\n const description = (spec as { description?: string }).description\n if (typeof description === 'string' && description.length > 0) node.description = description\n const type = (spec as { type?: string }).type\n if (type === undefined || type === 'json') return node\n if (type === 'object') {\n const objectSpec = spec as ObjectSpec\n node.type = 'object'\n node.additionalProperties = objectSpec.additionalProperties\n if (objectSpec.properties !== undefined) node.properties = compilePropertyMap(objectSpec.properties).properties\n return node\n }\n if (type === 'array') {\n node.type = 'array'\n const items = (spec as { items?: ValueSpec }).items\n if (items !== undefined) node.items = compileValue(items)\n return node\n }\n node.type = type\n const enumValues = (spec as ScalarSpec).enum\n if (enumValues !== undefined) node.enum = [...enumValues]\n const constValue = (spec as ScalarSpec).const\n if (constValue !== undefined) node.const = constValue\n return node\n}\n\n/** Compile a property map: properties + collected required list. */\nfunction compilePropertyMap(spec: Readonly<Record<string, ParameterSpec>>): { properties: Record<string, RawSchema>; required?: string[] } {\n const properties: Record<string, RawSchema> = {}\n const required: string[] = []\n for (const [name, entry] of Object.entries(spec)) {\n const { required: isRequired, ...valueSpec } = entry as ParameterSpec & Record<string, unknown>\n properties[name] = compileValue(valueSpec as ValueSpec)\n if (isRequired === true) required.push(name)\n }\n return required.length > 0 ? { properties, required } : { properties }\n}\n\n/** Does a JS value match a raw-subset scalar type? */\nfunction matchesScalarType(value: unknown, type: string): boolean {\n switch (type) {\n case 'string': return typeof value === 'string'\n case 'number': return typeof value === 'number'\n case 'integer': return typeof value === 'number' && Number.isInteger(value)\n case 'boolean': return typeof value === 'boolean'\n case 'null': return value === null\n default: return true\n }\n}\n\n/** Validate a value against the compiled subset; returns path-qualified violations. */\nfunction validateValue(schema: RawSchema, value: unknown, path: string): string[] {\n if (typeof schema.type !== 'string' || schema.type.length === 0) return []\n if (schema.type === 'object') {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return [`${path} must be an object`]\n const violations: string[] = []\n const present = value as Record<string, unknown>\n for (const key of (schema.required as string[] | undefined) ?? []) {\n if (!(key in present)) violations.push(`${path}.${key} is required`)\n }\n if (schema.additionalProperties === false) {\n const known = new Set(Object.keys((schema.properties as Record<string, RawSchema> | undefined) ?? {}))\n for (const key of Object.keys(present)) {\n if (!known.has(key)) violations.push(`${path}.${key} is not a declared property`)\n }\n }\n for (const [key, child] of Object.entries((schema.properties as Record<string, RawSchema> | undefined) ?? {})) {\n if (key in present) violations.push(...validateValue(child, present[key], `${path}.${key}`))\n }\n return violations\n }\n if (schema.type === 'array') {\n if (!Array.isArray(value)) return [`${path} must be an array`]\n const violations: string[] = []\n const items = schema.items as RawSchema | undefined\n if (items !== undefined) {\n value.forEach((item, index) => { violations.push(...validateValue(items, item, `${path}[${index}]`)) })\n }\n return violations\n }\n if (!matchesScalarType(value, schema.type)) return [`${path} must be ${schema.type}`]\n // T10: enum/const are compiled into the schema — validate them at runtime\n // too, so the \"pre-validates the same way\" promise holds for every node.\n const enumValues = schema.enum as unknown[] | undefined\n if (enumValues !== undefined && !enumValues.some(v => v === value)) {\n return [`${path} must be one of ${enumValues.map(String).join(', ')}`]\n }\n const constValue = (schema as { const?: unknown }).const\n if (constValue !== undefined && constValue !== value) {\n return [`${path} must be ${String(constValue)}`]\n }\n return []\n}\n\n/** Options shape we consume (a structural subset of the SDK's defineTool). */\nexport interface DefineToolOptions<A, V> {\n readonly name: string\n readonly description: string\n readonly parameters: Readonly<Record<string, ParameterSpec>>\n readonly output: {\n readonly schema: { readonly type: 'json' }\n render(args: A, value: V): Array<{ type: 'text'; text: string }>\n }\n execute(args: A, exec: unknown): Promise<V>\n}\n\n/** A registry-ready tool definition (structure-compatible with the SDK's). */\nexport interface ToolDefinition<A = unknown, V = unknown> {\n readonly name: string\n readonly description: string\n readonly parameters: RawSchema\n readonly output: {\n readonly schema: RawSchema\n render(args: A, value: V): Array<{ type: 'text'; text: string }>\n }\n execute(args: A, exec: unknown): Promise<V>\n}\n\n/**\n * Define a first-party tool: compile the parameter spec, pre-validate\n * arguments (message format matches the SDK's ToolArgsError), and pass\n * through the execution.\n */\nexport function defineTool<A extends Record<string, unknown>, V>(options: DefineToolOptions<A, V>): ToolDefinition<A, V> {\n const compiled = compilePropertyMap(options.parameters as Readonly<Record<string, ParameterSpec>>)\n const parameters: RawSchema = { type: 'object', properties: compiled.properties }\n if (compiled.required !== undefined) parameters.required = compiled.required\n const userExecute = options.execute\n return {\n name: options.name,\n description: options.description,\n parameters,\n output: {\n // The SDK compiles the `json` node to an annotation-only schema.\n schema: {},\n render(args, value) {\n return options.output.render(args, value)\n },\n },\n async execute(args, exec) {\n const violations = validateValue(parameters, args, 'arguments')\n if (violations.length > 0) {\n throw new Error(`Error: invalid arguments: ${violations.join('; ')}`)\n }\n return userExecute(args, exec)\n },\n }\n}\n"],"mappings":"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"}
|
package/lib/host/store.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { emptyLedger, pruneExecutions } from "../shared/protocol.js";
|
|
1
|
+
import { emptyLedger, isPlausibleTaskRecord, pruneExecutions } from "../shared/protocol.js";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import { mkdir, readFile, rename
|
|
3
|
+
import { mkdir, open, readFile, rename } from "node:fs/promises";
|
|
4
4
|
//#region src/host/store.ts
|
|
5
5
|
/**
|
|
6
6
|
* Host-side task ledger: one JSON file under the DSH home, mutated through a
|
|
@@ -32,7 +32,17 @@ var TaskStore = class {
|
|
|
32
32
|
const raw = await readFile(this.file, "utf8");
|
|
33
33
|
const parsed = JSON.parse(raw);
|
|
34
34
|
if (typeof parsed.revision === "number" && Array.isArray(parsed.tasks)) {
|
|
35
|
-
const
|
|
35
|
+
const plausible = [];
|
|
36
|
+
for (const entry of parsed.tasks) {
|
|
37
|
+
if (!isPlausibleTaskRecord(entry)) {
|
|
38
|
+
const rawId = entry?.id;
|
|
39
|
+
const id = typeof rawId === "string" ? rawId.slice(0, 60) : String(rawId);
|
|
40
|
+
console.warn("[dsh-taskboard] dropping implausible ledger entry on load:", id);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
plausible.push(entry);
|
|
44
|
+
}
|
|
45
|
+
const tasks = plausible;
|
|
36
46
|
for (const task of tasks) if (task.status === "in_progress" && task.claimedBy === void 0 && task.updatedBy?.kind === "agent" && typeof task.updatedBy.sessionId === "string") {
|
|
37
47
|
task.claimedBy = task.updatedBy.sessionId;
|
|
38
48
|
task.claimedAt = task.updatedAt;
|
|
@@ -92,7 +102,7 @@ var TaskStore = class {
|
|
|
92
102
|
const draft = structuredClone(this.ledger);
|
|
93
103
|
const changed = mutator(draft);
|
|
94
104
|
if (changed === void 0) return {
|
|
95
|
-
ledger: this.ledger,
|
|
105
|
+
ledger: deepFreeze(structuredClone(this.ledger)),
|
|
96
106
|
changed: []
|
|
97
107
|
};
|
|
98
108
|
for (const task of changed) pruneExecutions(task);
|
|
@@ -109,12 +119,25 @@ var TaskStore = class {
|
|
|
109
119
|
fn(change);
|
|
110
120
|
} catch {}
|
|
111
121
|
return {
|
|
112
|
-
ledger: draft,
|
|
113
|
-
changed
|
|
122
|
+
ledger: deepFreeze(structuredClone(draft)),
|
|
123
|
+
changed: changed.map((t) => deepFreeze(structuredClone(t)))
|
|
114
124
|
};
|
|
115
125
|
};
|
|
116
126
|
return this.queue = this.queue.then(run, run);
|
|
117
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Run a read INSIDE the serial queue (R3): observes exactly the ledger
|
|
130
|
+
* state after all previously enqueued mutations — immune to the
|
|
131
|
+
* write-then-publish window around `mutate`'s persistence. Read-only: the
|
|
132
|
+
* callback receives a frozen deep clone and nothing is written.
|
|
133
|
+
*/
|
|
134
|
+
async read(fn) {
|
|
135
|
+
const run = async () => {
|
|
136
|
+
await this.load();
|
|
137
|
+
return fn(deepFreeze(structuredClone(this.ledger)));
|
|
138
|
+
};
|
|
139
|
+
return this.queue = this.queue.then(run, run);
|
|
140
|
+
}
|
|
118
141
|
};
|
|
119
142
|
/** Recursively freeze a plain-data value (defense in depth for handed-out snapshots). */
|
|
120
143
|
function deepFreeze(value) {
|
|
@@ -124,11 +147,21 @@ function deepFreeze(value) {
|
|
|
124
147
|
}
|
|
125
148
|
return value;
|
|
126
149
|
}
|
|
127
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* Atomic file persist: write temp, fsync, then rename over the target (S10:
|
|
152
|
+
* without the sync, a power loss after rename can leave a zero-length file —
|
|
153
|
+
* the next load would quarantine the ledger and start empty).
|
|
154
|
+
*/
|
|
128
155
|
async function persistAtomic(file, contents) {
|
|
129
156
|
await mkdir(dirname(file), { recursive: true });
|
|
130
157
|
const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`);
|
|
131
|
-
await
|
|
158
|
+
const fh = await open(temp, "w");
|
|
159
|
+
try {
|
|
160
|
+
await fh.writeFile(contents, "utf8");
|
|
161
|
+
await fh.sync();
|
|
162
|
+
} finally {
|
|
163
|
+
await fh.close();
|
|
164
|
+
}
|
|
132
165
|
await rename(temp, file);
|
|
133
166
|
}
|
|
134
167
|
//#endregion
|
package/lib/host/store.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.js","names":[],"sources":["../../src/host/store.ts"],"sourcesContent":["/**\n * Host-side task ledger: one JSON file under the DSH home, mutated through a\n * serial write queue, published as immutable snapshots with a global\n * monotonic revision. Change subscribers (P2: SSE route) observe every\n * committed mutation.\n *\n * @module dsh-taskboard/host/store\n */\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport {\n LEDGER_SCHEMA_VERSION,\n emptyLedger,\n pruneExecutions,\n type TaskLedger,\n type TaskRecord,\n} from '../shared/protocol.ts'\n\n/** One committed ledger mutation, handed to change subscribers. */\nexport interface LedgerChange {\n /** Revision after the mutation. */\n revision: number\n /** The mutated tasks, if any (a comment purge may touch none). */\n tasks: readonly TaskRecord[]\n /** What kind of mutation this was (for SSE event naming later). */\n kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated'\n}\n\n/** Options for {@link TaskStore}. */\nexport interface TaskStoreOptions {\n /** Absolute ledger file path. */\n file: string\n}\n\n/**\n * The durable ledger. All mutations run through {@link mutate}, which:\n * validates the resulting document, bumps the global revision, persists\n * atomically (temp file + rename), and only then notifies subscribers.\n */\nexport class TaskStore {\n private readonly file: string\n private ledger: TaskLedger = emptyLedger()\n private readonly subscribers = new Set<(change: LedgerChange) => void>()\n private queue: Promise<unknown> = Promise.resolve()\n private loaded = false\n\n /** @param options - file location. */\n constructor(options: TaskStoreOptions) {\n this.file = options.file\n }\n\n /** Load (once) from disk; a missing file starts empty; a corrupt file is quarantined, not thrown. */\n async load(): Promise<void> {\n if (this.loaded) return\n try {\n const raw = await readFile(this.file, 'utf8')\n const parsed = JSON.parse(raw) as TaskLedger\n if (typeof parsed.revision === 'number' && Array.isArray(parsed.tasks)) {\n const tasks = parsed.tasks as TaskRecord[]\n // Migration from pre-claim-field ledgers: an agent-held in_progress\n // task carried its holder in updatedBy — backfill the explicit claim\n // fields so the hold survives user edits (updatedBy is audit-only).\n for (const task of tasks) {\n if (task.status === 'in_progress' && task.claimedBy === undefined\n && task.updatedBy?.kind === 'agent' && typeof task.updatedBy.sessionId === 'string') {\n task.claimedBy = task.updatedBy.sessionId\n task.claimedAt = task.updatedAt\n }\n }\n this.ledger = { schemaVersion: LEDGER_SCHEMA_VERSION, revision: parsed.revision, tasks }\n }\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code\n if (code !== 'ENOENT') {\n // Quarantine a corrupt ledger: rename it aside, start fresh. Never\n // take the host down over ledger damage.\n try {\n await rename(this.file, `${this.file}.corrupt-${Date.now()}`)\n } catch { /* best effort */ }\n }\n }\n this.loaded = true\n }\n\n /**\n * The current snapshot — a deep-frozen clone. Mutating the returned value\n * throws (strict mode) instead of silently bypassing the revision/persist\n * path; internal state is never handed out.\n */\n snapshot(): TaskLedger {\n return deepFreeze(structuredClone(this.ledger))\n }\n\n /** Find a task by id (frozen clone; internal state is never handed out). */\n get(id: string): TaskRecord | undefined {\n const task = this.ledger.tasks.find(t => t.id === id)\n return task === undefined ? undefined : deepFreeze(structuredClone(task))\n }\n\n /** Subscribe to committed changes; returns the unsubscribe. */\n subscribe(fn: (change: LedgerChange) => void): () => void {\n this.subscribers.add(fn)\n return () => this.subscribers.delete(fn)\n }\n\n /**\n * Write a timestamped backup copy of the current ledger next to the live\n * file (import-replace safety, 0.4.0). Never throws the caller's flow —\n * a backup failure fails the import itself.\n * @returns the backup file path.\n */\n async backup(): Promise<string> {\n await this.load()\n const target = `${this.file}.backup-${Date.now()}`\n await persistAtomic(target, JSON.stringify(this.ledger, null, 2))\n return target\n }\n\n /**\n * Run one mutation inside the serial queue. The mutator works on a\n * structured clone; returning `undefined` aborts with no write.\n * @param kind - change kind for subscribers.\n * @param mutator - receives the cloned ledger; mutate tasks in place; return the touched tasks.\n */\n async mutate(\n kind: LedgerChange['kind'],\n mutator: (ledger: TaskLedger) => TaskRecord[] | undefined,\n ): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> {\n const run = async (): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> => {\n await this.load()\n const draft: TaskLedger = structuredClone(this.ledger)\n const changed = mutator(draft)\n if (changed === undefined) {\n return { ledger: this.ledger, changed: [] }\n }\n // Retention cap: every committed mutation re-checks the touched tasks,\n // so execution history can never grow unbounded (SSE state payload).\n for (const task of changed) pruneExecutions(task)\n draft.revision += 1\n const json = JSON.stringify(draft)\n await persistAtomic(this.file, json)\n this.ledger = draft\n const change: LedgerChange = { revision: draft.revision, tasks: changed, kind }\n for (const fn of this.subscribers) {\n try {\n fn(change)\n } catch { /* subscriber errors never abort the write */ }\n }\n return { ledger: draft, changed }\n }\n const result = (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>\n return result\n }\n}\n\n/** Recursively freeze a plain-data value (defense in depth for handed-out snapshots). */\nfunction deepFreeze<T>(value: T): T {\n if (value !== null && typeof value === 'object') {\n if (!Object.isFrozen(value)) Object.freeze(value)\n for (const key of Object.keys(value as Record<string, unknown>)) {\n deepFreeze((value as Record<string, unknown>)[key])\n }\n }\n return value\n}\n\n/** Atomic file persist: write temp, then rename over the target. */\nasync function persistAtomic(file: string, contents: string): Promise<void> {\n await mkdir(dirname(file), { recursive: true })\n const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`)\n await writeFile(temp, contents, 'utf8')\n await rename(temp, file)\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"store.js","names":[],"sources":["../../src/host/store.ts"],"sourcesContent":["/**\n * Host-side task ledger: one JSON file under the DSH home, mutated through a\n * serial write queue, published as immutable snapshots with a global\n * monotonic revision. Change subscribers (P2: SSE route) observe every\n * committed mutation.\n *\n * @module dsh-taskboard/host/store\n */\nimport { mkdir, open, readFile, rename } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport {\n LEDGER_SCHEMA_VERSION,\n emptyLedger,\n isPlausibleTaskRecord,\n pruneExecutions,\n type TaskLedger,\n type TaskRecord,\n} from '../shared/protocol.ts'\n\n/** One committed ledger mutation, handed to change subscribers. */\nexport interface LedgerChange {\n /** Revision after the mutation. */\n revision: number\n /** The mutated tasks, if any (a comment purge may touch none). */\n tasks: readonly TaskRecord[]\n /** What kind of mutation this was (for SSE event naming later). */\n kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'\n}\n\n/** Options for {@link TaskStore}. */\nexport interface TaskStoreOptions {\n /** Absolute ledger file path. */\n file: string\n}\n\n/**\n * The durable ledger. All mutations run through {@link mutate}, which:\n * validates the resulting document, bumps the global revision, persists\n * atomically (temp file + rename), and only then notifies subscribers.\n */\nexport class TaskStore {\n private readonly file: string\n private ledger: TaskLedger = emptyLedger()\n private readonly subscribers = new Set<(change: LedgerChange) => void>()\n private queue: Promise<unknown> = Promise.resolve()\n private loaded = false\n\n /** @param options - file location. */\n constructor(options: TaskStoreOptions) {\n this.file = options.file\n }\n\n /** Load (once) from disk; a missing file starts empty; a corrupt file is quarantined, not thrown. */\n async load(): Promise<void> {\n if (this.loaded) return\n try {\n const raw = await readFile(this.file, 'utf8')\n const parsed = JSON.parse(raw) as TaskLedger\n if (typeof parsed.revision === 'number' && Array.isArray(parsed.tasks)) {\n // S11: trust no record wholesale — drop structurally broken entries\n // (including R4's traversal-shaped ids from a hand-edited file) with\n // a notice instead of letting them reach the path-building layers.\n const plausible: TaskRecord[] = []\n for (const entry of parsed.tasks as unknown[]) {\n if (!isPlausibleTaskRecord(entry)) {\n const rawId = (entry as { id?: unknown })?.id\n const id = typeof rawId === 'string' ? rawId.slice(0, 60) : String(rawId)\n console.warn('[dsh-taskboard] dropping implausible ledger entry on load:', id)\n continue\n }\n plausible.push(entry as TaskRecord)\n }\n const tasks = plausible\n // Migration from pre-claim-field ledgers: an agent-held in_progress\n // task carried its holder in updatedBy — backfill the explicit claim\n // fields so the hold survives user edits (updatedBy is audit-only).\n for (const task of tasks) {\n if (task.status === 'in_progress' && task.claimedBy === undefined\n && task.updatedBy?.kind === 'agent' && typeof task.updatedBy.sessionId === 'string') {\n task.claimedBy = task.updatedBy.sessionId\n task.claimedAt = task.updatedAt\n }\n }\n this.ledger = { schemaVersion: LEDGER_SCHEMA_VERSION, revision: parsed.revision, tasks }\n }\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code\n if (code !== 'ENOENT') {\n // Quarantine a corrupt ledger: rename it aside, start fresh. Never\n // take the host down over ledger damage.\n try {\n await rename(this.file, `${this.file}.corrupt-${Date.now()}`)\n } catch { /* best effort */ }\n }\n }\n this.loaded = true\n }\n\n /**\n * The current snapshot — a deep-frozen clone. Mutating the returned value\n * throws (strict mode) instead of silently bypassing the revision/persist\n * path; internal state is never handed out.\n */\n snapshot(): TaskLedger {\n return deepFreeze(structuredClone(this.ledger))\n }\n\n /** Find a task by id (frozen clone; internal state is never handed out). */\n get(id: string): TaskRecord | undefined {\n const task = this.ledger.tasks.find(t => t.id === id)\n return task === undefined ? undefined : deepFreeze(structuredClone(task))\n }\n\n /** Subscribe to committed changes; returns the unsubscribe. */\n subscribe(fn: (change: LedgerChange) => void): () => void {\n this.subscribers.add(fn)\n return () => this.subscribers.delete(fn)\n }\n\n /**\n * Write a timestamped backup copy of the current ledger next to the live\n * file (import-replace safety, 0.4.0). Never throws the caller's flow —\n * a backup failure fails the import itself.\n * @returns the backup file path.\n */\n async backup(): Promise<string> {\n await this.load()\n const target = `${this.file}.backup-${Date.now()}`\n await persistAtomic(target, JSON.stringify(this.ledger, null, 2))\n return target\n }\n\n /**\n * Run one mutation inside the serial queue. The mutator works on a\n * structured clone; returning `undefined` aborts with no write.\n * @param kind - change kind for subscribers.\n * @param mutator - receives the cloned ledger; mutate tasks in place; return the touched tasks.\n */\n async mutate(\n kind: LedgerChange['kind'],\n mutator: (ledger: TaskLedger) => TaskRecord[] | undefined,\n ): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> {\n const run = async (): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> => {\n await this.load()\n const draft: TaskLedger = structuredClone(this.ledger)\n const changed = mutator(draft)\n if (changed === undefined) {\n // S9 parity: even a no-op mutation hands out a frozen clone — never\n // the live internal ledger.\n return { ledger: deepFreeze(structuredClone(this.ledger)), changed: [] }\n }\n // Retention cap: every committed mutation re-checks the touched tasks,\n // so execution history can never grow unbounded (SSE state payload).\n for (const task of changed) pruneExecutions(task)\n draft.revision += 1\n const json = JSON.stringify(draft)\n await persistAtomic(this.file, json)\n this.ledger = draft\n const change: LedgerChange = { revision: draft.revision, tasks: changed, kind }\n for (const fn of this.subscribers) {\n try {\n fn(change)\n } catch { /* subscriber errors never abort the write */ }\n }\n // S9: hand out frozen clones — the return value used to BE the new\n // internal ledger; callers must never mutate internal state in place.\n return {\n ledger: deepFreeze(structuredClone(draft)),\n changed: changed.map(t => deepFreeze(structuredClone(t))),\n }\n }\n const result = (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>\n return result\n }\n\n /**\n * Run a read INSIDE the serial queue (R3): observes exactly the ledger\n * state after all previously enqueued mutations — immune to the\n * write-then-publish window around `mutate`'s persistence. Read-only: the\n * callback receives a frozen deep clone and nothing is written.\n */\n async read<T>(fn: (ledger: TaskLedger) => T): Promise<T> {\n const run = async (): Promise<T> => {\n await this.load()\n return fn(deepFreeze(structuredClone(this.ledger)))\n }\n const result = (this.queue = this.queue.then(run, run)) as Promise<T>\n return result\n }\n}\n\n/** Recursively freeze a plain-data value (defense in depth for handed-out snapshots). */\nfunction deepFreeze<T>(value: T): T {\n if (value !== null && typeof value === 'object') {\n if (!Object.isFrozen(value)) Object.freeze(value)\n for (const key of Object.keys(value as Record<string, unknown>)) {\n deepFreeze((value as Record<string, unknown>)[key])\n }\n }\n return value\n}\n\n/**\n * Atomic file persist: write temp, fsync, then rename over the target (S10:\n * without the sync, a power loss after rename can leave a zero-length file —\n * the next load would quarantine the ledger and start empty).\n */\nasync function persistAtomic(file: string, contents: string): Promise<void> {\n await mkdir(dirname(file), { recursive: true })\n const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`)\n const fh = await open(temp, 'w')\n try {\n await fh.writeFile(contents, 'utf8')\n await fh.sync()\n } finally {\n await fh.close()\n }\n await rename(temp, file)\n}\n"],"mappings":"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"}
|
package/lib/host/templates.js
CHANGED
|
@@ -106,13 +106,19 @@ var TemplateStore = class {
|
|
|
106
106
|
this.templates = parsed;
|
|
107
107
|
this.loaded = true;
|
|
108
108
|
}
|
|
109
|
-
/** Atomic persist (temp + rename
|
|
109
|
+
/** Atomic persist (temp + fsync + rename — S10, same discipline as the ledger). */
|
|
110
110
|
async persist(templates) {
|
|
111
|
-
const { mkdir,
|
|
111
|
+
const { mkdir, open, rename } = await import("node:fs/promises");
|
|
112
112
|
const { dirname, join } = await import("node:path");
|
|
113
113
|
await mkdir(dirname(this.file), { recursive: true });
|
|
114
114
|
const temp = join(dirname(this.file), `.${Math.random().toString(36).slice(2)}.tmp`);
|
|
115
|
-
await
|
|
115
|
+
const fh = await open(temp, "w");
|
|
116
|
+
try {
|
|
117
|
+
await fh.writeFile(JSON.stringify({ templates }, null, 2), "utf8");
|
|
118
|
+
await fh.sync();
|
|
119
|
+
} finally {
|
|
120
|
+
await fh.close();
|
|
121
|
+
}
|
|
116
122
|
await rename(temp, this.file);
|
|
117
123
|
}
|
|
118
124
|
/** All templates (oldest first). */
|
|
@@ -131,6 +137,7 @@ var TemplateStore = class {
|
|
|
131
137
|
if (name.length === 0 || name.length > 60) throw new Error("模板名必须 1..60 字符");
|
|
132
138
|
const now = Date.now();
|
|
133
139
|
const existing = input.id !== void 0 ? templates.find((t) => t.id === input.id) : void 0;
|
|
140
|
+
if (existing?.builtin === true) throw new Error("内置模板不可覆盖;可删除后另建,或以新名称存为新模板");
|
|
134
141
|
const stored = existing !== void 0 ? {
|
|
135
142
|
...existing,
|
|
136
143
|
name,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"templates.js","names":[],"sources":["../../src/host/templates.ts"],"sourcesContent":["/**\n * Host-side task-template store (0.4.0): one JSON side file next to the\n * ledger, seeded with the built-in templates on first load, mutated through\n * the same atomic persist discipline as the ledger.\n *\n * Pure data, no Cordis deps — the routes layer owns it and tests drive it\n * directly against a temp dir.\n *\n * @module dsh-taskboard/host/templates\n */\nimport { readFile } from 'node:fs/promises'\nimport type { TaskTemplate } from '../shared/api.ts'\n\n/** The built-in templates seeded when the side file does not exist yet. */\nexport const BUILTIN_TEMPLATES: ReadonlyArray<{ id: string; name: string; task: TaskTemplate['task'] }> = [\n {\n id: 'tpl-bugfix',\n name: 'Bug 修复',\n task: {\n title: '修复:',\n prompt: [\n '修复以下问题并按序交接:',\n '1. 复现问题(写最小复现步骤或测试)',\n '2. 定位根因,说明为什么会发生',\n '3. 修复并补回归测试',\n '4. 运行相关测试套件确认无回归',\n ].join('\\n'),\n urgency: 'urgent',\n checklist: ['已复现并定位根因', '修复已提交到任务分支', '回归测试通过'],\n },\n },\n {\n id: 'tpl-release',\n name: '发布检查',\n task: {\n title: '发布:',\n prompt: '执行发布流程:版本号更新、构建、测试、变更记录,完成后按序交接(不要实际推送/发布,等用户确认)。',\n urgency: 'normal',\n checklist: ['版本号已更新(package.json 与版本常量同步)', '构建通过', '全部测试通过', '变更记录已写'],\n },\n },\n {\n id: 'tpl-patrol',\n name: '例行巡检',\n task: {\n title: '巡检:',\n prompt: [\n '例行巡检:检查依赖更新、失败测试、明显代码问题与未处理的告警。',\n '发现的问题逐条列出(严重度/位置/建议),小问题直接修复,大问题只报告不动手。',\n '输出巡检摘要(用 {{lastComments}} 可回看上次巡检结论)。',\n ].join('\\n'),\n urgency: 'relaxed',\n execution: { mode: 'scheduled', cron: '0 9 * * 1' },\n },\n },\n]\n\n/** Mint a template id. */\nfunction newTemplateId(): string {\n return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`\n}\n\n/**\n * The template store. NOT thread-synchronized like the ledger (template\n * writes are rare, human-paced GUI operations; last-write-wins is fine).\n */\nexport class TemplateStore {\n private templates: TaskTemplate[] | undefined\n private loaded = false\n\n /** @param file - absolute side-file path (next to the ledger). */\n constructor(private readonly file: string) {}\n\n /** Load once; a missing file seeds the built-ins; a corrupt file resets. */\n private async ensure(): Promise<void> {\n if (this.loaded) return\n let parsed: TaskTemplate[] | undefined\n try {\n const raw = await readFile(this.file, 'utf8')\n const value = JSON.parse(raw) as { templates?: unknown }\n if (Array.isArray(value.templates)) {\n parsed = value.templates.filter((t): t is TaskTemplate =>\n typeof t === 'object' && t !== null && typeof (t as TaskTemplate).id === 'string'\n && typeof (t as TaskTemplate).name === 'string' && typeof (t as TaskTemplate).task === 'object')\n }\n } catch { /* missing or corrupt → seed */ }\n if (parsed === undefined) {\n const now = Date.now()\n parsed = BUILTIN_TEMPLATES.map((t, i) => ({ ...t, task: { ...t.task }, builtin: true, createdAt: now, updatedAt: now + i }))\n try { await this.persist(parsed) } catch { /* best effort — the seed returns in-memory */ }\n }\n this.templates = parsed\n this.loaded = true\n }\n\n /** Atomic persist (temp + rename
|
|
1
|
+
{"version":3,"file":"templates.js","names":[],"sources":["../../src/host/templates.ts"],"sourcesContent":["/**\n * Host-side task-template store (0.4.0): one JSON side file next to the\n * ledger, seeded with the built-in templates on first load, mutated through\n * the same atomic persist discipline as the ledger.\n *\n * Pure data, no Cordis deps — the routes layer owns it and tests drive it\n * directly against a temp dir.\n *\n * @module dsh-taskboard/host/templates\n */\nimport { readFile } from 'node:fs/promises'\nimport type { TaskTemplate } from '../shared/api.ts'\n\n/** The built-in templates seeded when the side file does not exist yet. */\nexport const BUILTIN_TEMPLATES: ReadonlyArray<{ id: string; name: string; task: TaskTemplate['task'] }> = [\n {\n id: 'tpl-bugfix',\n name: 'Bug 修复',\n task: {\n title: '修复:',\n prompt: [\n '修复以下问题并按序交接:',\n '1. 复现问题(写最小复现步骤或测试)',\n '2. 定位根因,说明为什么会发生',\n '3. 修复并补回归测试',\n '4. 运行相关测试套件确认无回归',\n ].join('\\n'),\n urgency: 'urgent',\n checklist: ['已复现并定位根因', '修复已提交到任务分支', '回归测试通过'],\n },\n },\n {\n id: 'tpl-release',\n name: '发布检查',\n task: {\n title: '发布:',\n prompt: '执行发布流程:版本号更新、构建、测试、变更记录,完成后按序交接(不要实际推送/发布,等用户确认)。',\n urgency: 'normal',\n checklist: ['版本号已更新(package.json 与版本常量同步)', '构建通过', '全部测试通过', '变更记录已写'],\n },\n },\n {\n id: 'tpl-patrol',\n name: '例行巡检',\n task: {\n title: '巡检:',\n prompt: [\n '例行巡检:检查依赖更新、失败测试、明显代码问题与未处理的告警。',\n '发现的问题逐条列出(严重度/位置/建议),小问题直接修复,大问题只报告不动手。',\n '输出巡检摘要(用 {{lastComments}} 可回看上次巡检结论)。',\n ].join('\\n'),\n urgency: 'relaxed',\n execution: { mode: 'scheduled', cron: '0 9 * * 1' },\n },\n },\n]\n\n/** Mint a template id. */\nfunction newTemplateId(): string {\n return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`\n}\n\n/**\n * The template store. NOT thread-synchronized like the ledger (template\n * writes are rare, human-paced GUI operations; last-write-wins is fine).\n */\nexport class TemplateStore {\n private templates: TaskTemplate[] | undefined\n private loaded = false\n\n /** @param file - absolute side-file path (next to the ledger). */\n constructor(private readonly file: string) {}\n\n /** Load once; a missing file seeds the built-ins; a corrupt file resets. */\n private async ensure(): Promise<void> {\n if (this.loaded) return\n let parsed: TaskTemplate[] | undefined\n try {\n const raw = await readFile(this.file, 'utf8')\n const value = JSON.parse(raw) as { templates?: unknown }\n if (Array.isArray(value.templates)) {\n parsed = value.templates.filter((t): t is TaskTemplate =>\n typeof t === 'object' && t !== null && typeof (t as TaskTemplate).id === 'string'\n && typeof (t as TaskTemplate).name === 'string' && typeof (t as TaskTemplate).task === 'object')\n }\n } catch { /* missing or corrupt → seed */ }\n if (parsed === undefined) {\n const now = Date.now()\n parsed = BUILTIN_TEMPLATES.map((t, i) => ({ ...t, task: { ...t.task }, builtin: true, createdAt: now, updatedAt: now + i }))\n try { await this.persist(parsed) } catch { /* best effort — the seed returns in-memory */ }\n }\n this.templates = parsed\n this.loaded = true\n }\n\n /** Atomic persist (temp + fsync + rename — S10, same discipline as the ledger). */\n private async persist(templates: TaskTemplate[]): Promise<void> {\n const { mkdir, open, rename } = await import('node:fs/promises')\n const { dirname, join } = await import('node:path')\n await mkdir(dirname(this.file), { recursive: true })\n const temp = join(dirname(this.file), `.${Math.random().toString(36).slice(2)}.tmp`)\n const fh = await open(temp, 'w')\n try {\n await fh.writeFile(JSON.stringify({ templates }, null, 2), 'utf8')\n await fh.sync()\n } finally {\n await fh.close()\n }\n await rename(temp, this.file)\n }\n\n /** All templates (oldest first). */\n async list(): Promise<TaskTemplate[]> {\n await this.ensure()\n return (this.templates ?? []).slice()\n }\n\n /**\n * Create or replace a template by id (a body without id creates).\n * @returns the stored template.\n */\n async upsert(input: { id?: string; name: string; task: TaskTemplate['task'] }): Promise<TaskTemplate> {\n await this.ensure()\n const templates = this.templates ?? []\n const name = input.name.trim()\n if (name.length === 0 || name.length > 60) throw new Error('模板名必须 1..60 字符')\n const now = Date.now()\n const existing = input.id !== undefined ? templates.find(t => t.id === input.id) : undefined\n // T12: built-ins are factory content — editable only by delete + recreate\n // (deleting stays allowed), never silently overwritten in place.\n if (existing?.builtin === true) throw new Error('内置模板不可覆盖;可删除后另建,或以新名称存为新模板')\n const stored: TaskTemplate = existing !== undefined\n ? { ...existing, name, task: input.task, updatedAt: now }\n : { id: input.id ?? newTemplateId(), name, task: input.task, createdAt: now, updatedAt: now }\n const index = existing !== undefined ? templates.indexOf(existing) : -1\n if (index >= 0) templates[index] = stored\n else templates.push(stored)\n await this.persist(templates)\n return stored\n }\n\n /** Delete a template by id; returns whether it existed. */\n async remove(id: string): Promise<boolean> {\n await this.ensure()\n const templates = this.templates ?? []\n const index = templates.findIndex(t => t.id === id)\n if (index < 0) return false\n templates.splice(index, 1)\n await this.persist(templates)\n return true\n }\n}\n"],"mappings":"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"}
|
package/lib/host/tools.js
CHANGED
|
@@ -53,7 +53,7 @@ function taskDetail(t) {
|
|
|
53
53
|
lines.push(` - [${e.trigger} ${at}] ${e.outcome}${report}${err}`);
|
|
54
54
|
}
|
|
55
55
|
} else lines.push("执行记录: 无");
|
|
56
|
-
const updatedBy = t.updatedBy.kind === "agent" ? `agent ${String(t.updatedBy.sessionId).slice(0, 24)}` : "user";
|
|
56
|
+
const updatedBy = t.updatedBy.kind === "agent" ? `agent ${String(t.updatedBy.sessionId).slice(0, 24)}` : t.updatedBy.kind === "system" ? "system" : "user";
|
|
57
57
|
lines.push(`更新: ${new Date(t.updatedAt).toISOString()} 由 ${updatedBy}`);
|
|
58
58
|
return lines.join("\n");
|
|
59
59
|
}
|
|
@@ -67,7 +67,9 @@ const ERR = {
|
|
|
67
67
|
requiresAgent: "unauthorized_actor",
|
|
68
68
|
invalidInput: "invalid_input"
|
|
69
69
|
};
|
|
70
|
-
/** Tool failure: an Error whose message starts with a stable code.
|
|
70
|
+
/** Tool failure: an Error whose message starts with a stable code. The code
|
|
71
|
+
* is also carried structurally so the routes layer can map failures without
|
|
72
|
+
* re-parsing messages (review P2). */
|
|
71
73
|
var ToolError = class extends Error {
|
|
72
74
|
code;
|
|
73
75
|
constructor(code, detail) {
|
|
@@ -127,6 +129,22 @@ function versionGuard(task, ifVersion) {
|
|
|
127
129
|
if (ifVersion === void 0) throw new ToolError(ERR.versionConflict, "this write requires ifVersion; read the task first");
|
|
128
130
|
if (ifVersion !== task.version) throw new ToolError(ERR.versionConflict, `stale version ${ifVersion} (current ${task.version}); re-read the task and retry once`);
|
|
129
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Find a live (non-trashed) task INSIDE a mutator (R1: every guard must run
|
|
134
|
+
* on the fresh draft the serial queue hands us, never on a pre-read clone —
|
|
135
|
+
* a pre-read can pass its version check and then blind-overwrite a task that
|
|
136
|
+
* changed while the caller awaited). Throws not_found for missing/trashed.
|
|
137
|
+
*/
|
|
138
|
+
function liveTaskAt(ledger, id) {
|
|
139
|
+
const index = ledger.tasks.findIndex((t) => t.id === id);
|
|
140
|
+
if (index < 0) throw new ToolError(ERR.notFound, `no task ${id}`);
|
|
141
|
+
const task = ledger.tasks[index];
|
|
142
|
+
if (task.trashedAt !== void 0) throw new ToolError(ERR.notFound, `no task ${id}`);
|
|
143
|
+
return {
|
|
144
|
+
index,
|
|
145
|
+
task
|
|
146
|
+
};
|
|
147
|
+
}
|
|
130
148
|
/** Re-throw with a stable code; non-ToolErrors become invalid_input. */
|
|
131
149
|
function fail(error) {
|
|
132
150
|
if (error instanceof ToolError) throw error;
|
|
@@ -140,7 +158,7 @@ function json(value) {
|
|
|
140
158
|
return JSON.parse(JSON.stringify(value));
|
|
141
159
|
}
|
|
142
160
|
/**
|
|
143
|
-
* Register all
|
|
161
|
+
* Register all ten tools.
|
|
144
162
|
* @param ctx - a context exposing `tools.register`.
|
|
145
163
|
* @param deps - store + workspaces + clock.
|
|
146
164
|
* @returns dispose functions, one per tool.
|
|
@@ -339,12 +357,13 @@ function registerTaskboardTools(ctx, deps) {
|
|
|
339
357
|
if (workspaces.get(args.workspaceId) === void 0) throw new ToolError(ERR.notFound, `unknown workspaceId ${args.workspaceId}`);
|
|
340
358
|
const urgency = asUrgency(args.urgency);
|
|
341
359
|
const status = args.status === void 0 ? "todo" : asStatus(args.status);
|
|
342
|
-
if (status
|
|
360
|
+
if (status !== "backlog" && status !== "todo") throw new ToolError(ERR.invalidTransition, "a new task must start as backlog or todo (in_progress requires claiming the task)");
|
|
343
361
|
const execution = normalizeExecution(args.execution ?? {}, deps.now());
|
|
344
362
|
const model = args.model !== void 0 ? checkModel(deps, args.model) : void 0;
|
|
345
363
|
const isolation = args.isolation === void 0 ? defaultIsolationOf(store.snapshot().settings) : asIsolation(args.isolation);
|
|
346
364
|
const presetId = args.presetId?.trim() || void 0;
|
|
347
|
-
const
|
|
365
|
+
const checklistTexts = args.checklist?.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
366
|
+
const checklist = checklistTexts !== void 0 && checklistTexts.length > 0 ? checklistFromTexts(checklistTexts) : void 0;
|
|
348
367
|
const now = deps.now();
|
|
349
368
|
const task = {
|
|
350
369
|
id: newTaskId(),
|
|
@@ -426,22 +445,21 @@ function registerTaskboardTools(ctx, deps) {
|
|
|
426
445
|
async execute(args, exec) {
|
|
427
446
|
try {
|
|
428
447
|
const { actor } = caller(exec);
|
|
429
|
-
|
|
430
|
-
if (task === void 0 || task.trashedAt !== void 0) throw new ToolError(ERR.notFound, `no task ${args.id}`);
|
|
431
|
-
versionGuard(task, args.ifVersion);
|
|
432
|
-
if (task.status === "archived") throw new ToolError(ERR.invalidTransition, "archived tasks are immutable");
|
|
433
|
-
const next = structuredClone(task);
|
|
434
|
-
if (args.title !== void 0) next.title = normalizeTitle(args.title);
|
|
435
|
-
if (args.description !== void 0) next.description = args.description.trim();
|
|
436
|
-
if (args.prompt !== void 0) next.prompt = normalizePrompt(args.prompt);
|
|
437
|
-
if (args.urgency !== void 0) next.urgency = asUrgency(args.urgency);
|
|
438
|
-
if (args.blocked !== void 0) next.blocked = args.blocked;
|
|
439
|
-
next.version = task.version + 1;
|
|
440
|
-
next.updatedAt = deps.now();
|
|
441
|
-
next.updatedBy = actor;
|
|
448
|
+
let next;
|
|
442
449
|
await store.mutate("task-updated", (ledger) => {
|
|
443
|
-
const
|
|
444
|
-
|
|
450
|
+
const { index, task } = liveTaskAt(ledger, args.id);
|
|
451
|
+
versionGuard(task, args.ifVersion);
|
|
452
|
+
if (task.status === "archived") throw new ToolError(ERR.invalidTransition, "archived tasks are immutable");
|
|
453
|
+
next = structuredClone(task);
|
|
454
|
+
if (args.title !== void 0) next.title = normalizeTitle(args.title);
|
|
455
|
+
if (args.description !== void 0) next.description = args.description.trim();
|
|
456
|
+
if (args.prompt !== void 0) next.prompt = normalizePrompt(args.prompt);
|
|
457
|
+
if (args.urgency !== void 0) next.urgency = asUrgency(args.urgency);
|
|
458
|
+
if (args.blocked !== void 0) next.blocked = args.blocked;
|
|
459
|
+
next.version = task.version + 1;
|
|
460
|
+
next.updatedAt = deps.now();
|
|
461
|
+
next.updatedBy = actor;
|
|
462
|
+
ledger.tasks[index] = next;
|
|
445
463
|
return [next];
|
|
446
464
|
});
|
|
447
465
|
return json({ task: summarize(next) });
|
|
@@ -484,25 +502,23 @@ function registerTaskboardTools(ctx, deps) {
|
|
|
484
502
|
try {
|
|
485
503
|
const { actor } = caller(exec);
|
|
486
504
|
const to = asStatus(args.status);
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
versionGuard(task, args.ifVersion);
|
|
490
|
-
if (to === "done") throw new ToolError(ERR.forbidden, "moving a task to done requires explicit user confirmation (GUI); agents cannot do it");
|
|
491
|
-
if (!canTransition(task.status, to)) throw new ToolError(ERR.invalidTransition, `illegal transition ${task.status} → ${to}`);
|
|
492
|
-
if (task.status === "in_progress" && task.claimedBy !== void 0 && task.claimedBy !== actor.sessionId) throw new ToolError(ERR.forbidden, `task is held by session ${task.claimedBy}; never take over another session's claim`);
|
|
493
|
-
if (isClaim(task.status, to)) {
|
|
494
|
-
if (await callerWorkspace(deps, exec) !== task.workspaceId) throw new ToolError(ERR.workspaceMismatch, "only a session inside this task's project may claim it");
|
|
495
|
-
}
|
|
496
|
-
const next = structuredClone(task);
|
|
497
|
-
next.status = to;
|
|
498
|
-
next.version = task.version + 1;
|
|
499
|
-
next.updatedAt = deps.now();
|
|
500
|
-
next.updatedBy = actor;
|
|
501
|
-
if (isClaim(task.status, to)) next.blocked = false;
|
|
502
|
-
syncClaim(next, to, deps.now(), isClaim(task.status, to) ? actor.sessionId : void 0);
|
|
505
|
+
const callerWsId = to === "in_progress" ? await callerWorkspace(deps, exec) : void 0;
|
|
506
|
+
let next;
|
|
503
507
|
await store.mutate("task-moved", (ledger) => {
|
|
504
|
-
const
|
|
505
|
-
|
|
508
|
+
const { index, task } = liveTaskAt(ledger, args.id);
|
|
509
|
+
versionGuard(task, args.ifVersion);
|
|
510
|
+
if (to === "done") throw new ToolError(ERR.forbidden, "moving a task to done requires explicit user confirmation (GUI); agents cannot do it");
|
|
511
|
+
if (!canTransition(task.status, to)) throw new ToolError(ERR.invalidTransition, `illegal transition ${task.status} → ${to}`);
|
|
512
|
+
if (task.status === "in_progress" && task.claimedBy !== void 0 && task.claimedBy !== actor.sessionId) throw new ToolError(ERR.forbidden, `task is held by session ${task.claimedBy}; never take over another session's claim`);
|
|
513
|
+
if (isClaim(task.status, to) && callerWsId !== task.workspaceId) throw new ToolError(ERR.workspaceMismatch, "only a session inside this task's project may claim it");
|
|
514
|
+
next = structuredClone(task);
|
|
515
|
+
next.status = to;
|
|
516
|
+
next.version = task.version + 1;
|
|
517
|
+
next.updatedAt = deps.now();
|
|
518
|
+
next.updatedBy = actor;
|
|
519
|
+
if (isClaim(task.status, to)) next.blocked = false;
|
|
520
|
+
syncClaim(next, to, deps.now(), isClaim(task.status, to) ? actor.sessionId : void 0);
|
|
521
|
+
ledger.tasks[index] = next;
|
|
506
522
|
return [next];
|
|
507
523
|
});
|
|
508
524
|
return json({ task: summarize(next) });
|
|
@@ -545,8 +561,6 @@ function registerTaskboardTools(ctx, deps) {
|
|
|
545
561
|
async execute(args, exec) {
|
|
546
562
|
try {
|
|
547
563
|
const { sessionId } = caller(exec);
|
|
548
|
-
const task = store.get(args.id);
|
|
549
|
-
if (task === void 0 || task.trashedAt !== void 0) throw new ToolError(ERR.notFound, `no task ${args.id}`);
|
|
550
564
|
const comment = {
|
|
551
565
|
id: newCommentId(),
|
|
552
566
|
body: normalizeBody(args.body),
|
|
@@ -554,13 +568,15 @@ function registerTaskboardTools(ctx, deps) {
|
|
|
554
568
|
createdAt: deps.now(),
|
|
555
569
|
threadId: sessionId
|
|
556
570
|
};
|
|
557
|
-
|
|
558
|
-
next.comments.push(comment);
|
|
559
|
-
next.version = task.version + 1;
|
|
560
|
-
next.updatedAt = deps.now();
|
|
571
|
+
let next;
|
|
561
572
|
await store.mutate("comment-added", (ledger) => {
|
|
562
|
-
const
|
|
563
|
-
|
|
573
|
+
const { index, task } = liveTaskAt(ledger, args.id);
|
|
574
|
+
if (task.status === "archived") throw new ToolError(ERR.invalidTransition, "archived tasks are immutable");
|
|
575
|
+
next = structuredClone(task);
|
|
576
|
+
next.comments.push(comment);
|
|
577
|
+
next.version = task.version + 1;
|
|
578
|
+
next.updatedAt = deps.now();
|
|
579
|
+
ledger.tasks[index] = next;
|
|
564
580
|
return [next];
|
|
565
581
|
});
|
|
566
582
|
return json({
|
|
@@ -638,15 +654,18 @@ function registerTaskboardTools(ctx, deps) {
|
|
|
638
654
|
async execute(args, exec) {
|
|
639
655
|
try {
|
|
640
656
|
caller(exec);
|
|
641
|
-
|
|
642
|
-
if (task === void 0) throw new ToolError(ERR.notFound, `no task ${args.id}`);
|
|
643
|
-
versionGuard(task, args.ifVersion);
|
|
644
|
-
const next = structuredClone(task);
|
|
645
|
-
next.trashedAt = deps.now();
|
|
646
|
-
next.version = task.version + 1;
|
|
657
|
+
let next;
|
|
647
658
|
await store.mutate("task-deleted", (ledger) => {
|
|
648
|
-
const
|
|
649
|
-
|
|
659
|
+
const { index, task } = liveTaskAt(ledger, args.id);
|
|
660
|
+
versionGuard(task, args.ifVersion);
|
|
661
|
+
if (task.executions.some((e) => e.outcome === "running")) throw new ToolError(ERR.invalidInput, "任务有正在运行的执行(先在 GUI 取消或等它结束再删除)");
|
|
662
|
+
next = structuredClone(task);
|
|
663
|
+
next.trashedAt = deps.now();
|
|
664
|
+
next.version = task.version + 1;
|
|
665
|
+
delete next.claimedBy;
|
|
666
|
+
delete next.claimedAt;
|
|
667
|
+
next.blocked = false;
|
|
668
|
+
ledger.tasks[index] = next;
|
|
650
669
|
return [next];
|
|
651
670
|
});
|
|
652
671
|
return { trashed: true };
|
|
@@ -706,43 +725,42 @@ function registerTaskboardTools(ctx, deps) {
|
|
|
706
725
|
async execute(args, exec) {
|
|
707
726
|
try {
|
|
708
727
|
const { actor } = caller(exec);
|
|
709
|
-
|
|
710
|
-
if (task === void 0 || task.trashedAt !== void 0) throw new ToolError(ERR.notFound, `no task ${args.id}`);
|
|
711
|
-
versionGuard(task, args.ifVersion);
|
|
712
|
-
if (task.status === "archived") throw new ToolError(ERR.invalidTransition, "archived tasks are immutable");
|
|
713
|
-
const next = structuredClone(task);
|
|
714
|
-
const checklist = next.checklist === void 0 ? [] : [...next.checklist];
|
|
715
|
-
if (args.action === "add") {
|
|
716
|
-
const texts = args.items ?? [];
|
|
717
|
-
if (texts.length === 0 || texts.length > 10) throw new ToolError(ERR.invalidInput, "items must carry 1..10 texts per add call");
|
|
718
|
-
if (checklist.length + texts.length > 30) throw new ToolError(ERR.invalidInput, `checklist may hold at most 30 items (currently ${checklist.length})`);
|
|
719
|
-
checklist.push(...checklistFromTexts(texts));
|
|
720
|
-
} else if (args.action === "check") {
|
|
721
|
-
if (args.itemId === void 0) throw new ToolError(ERR.invalidInput, "itemId is required for check");
|
|
722
|
-
const item = checklist.find((i) => i.id === args.itemId);
|
|
723
|
-
if (item === void 0) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`);
|
|
724
|
-
const note = args.note !== void 0 && args.note.trim().length > 0 ? args.note.trim().slice(0, 400) : void 0;
|
|
725
|
-
item.checked = true;
|
|
726
|
-
item.checkedBy = actor.sessionId;
|
|
727
|
-
item.checkedAt = deps.now();
|
|
728
|
-
if (note !== void 0) item.note = note;
|
|
729
|
-
} else if (args.action === "uncheck") {
|
|
730
|
-
if (args.itemId === void 0) throw new ToolError(ERR.invalidInput, "itemId is required for uncheck");
|
|
731
|
-
const item = checklist.find((i) => i.id === args.itemId);
|
|
732
|
-
if (item === void 0) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`);
|
|
733
|
-
item.checked = false;
|
|
734
|
-
delete item.checkedBy;
|
|
735
|
-
delete item.checkedAt;
|
|
736
|
-
delete item.note;
|
|
737
|
-
} else throw new ToolError(ERR.invalidInput, `action must be add | check | uncheck (got "${args.action}")`);
|
|
738
|
-
if (checklist.length > 0) next.checklist = checklist;
|
|
739
|
-
else delete next.checklist;
|
|
740
|
-
next.version = task.version + 1;
|
|
741
|
-
next.updatedAt = deps.now();
|
|
742
|
-
next.updatedBy = actor;
|
|
728
|
+
let next;
|
|
743
729
|
await store.mutate("task-updated", (ledger) => {
|
|
744
|
-
const
|
|
745
|
-
|
|
730
|
+
const { index, task } = liveTaskAt(ledger, args.id);
|
|
731
|
+
versionGuard(task, args.ifVersion);
|
|
732
|
+
if (task.status === "archived") throw new ToolError(ERR.invalidTransition, "archived tasks are immutable");
|
|
733
|
+
next = structuredClone(task);
|
|
734
|
+
const checklist = next.checklist === void 0 ? [] : [...next.checklist];
|
|
735
|
+
if (args.action === "add") {
|
|
736
|
+
const texts = args.items ?? [];
|
|
737
|
+
if (texts.length === 0 || texts.length > 10) throw new ToolError(ERR.invalidInput, "items must carry 1..10 texts per add call");
|
|
738
|
+
if (checklist.length + texts.length > 30) throw new ToolError(ERR.invalidInput, `checklist may hold at most 30 items (currently ${checklist.length})`);
|
|
739
|
+
checklist.push(...checklistFromTexts(texts));
|
|
740
|
+
} else if (args.action === "check") {
|
|
741
|
+
if (args.itemId === void 0) throw new ToolError(ERR.invalidInput, "itemId is required for check");
|
|
742
|
+
const item = checklist.find((i) => i.id === args.itemId);
|
|
743
|
+
if (item === void 0) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`);
|
|
744
|
+
const note = args.note !== void 0 && args.note.trim().length > 0 ? args.note.trim().slice(0, 400) : void 0;
|
|
745
|
+
item.checked = true;
|
|
746
|
+
item.checkedBy = actor.sessionId;
|
|
747
|
+
item.checkedAt = deps.now();
|
|
748
|
+
if (note !== void 0) item.note = note;
|
|
749
|
+
} else if (args.action === "uncheck") {
|
|
750
|
+
if (args.itemId === void 0) throw new ToolError(ERR.invalidInput, "itemId is required for uncheck");
|
|
751
|
+
const item = checklist.find((i) => i.id === args.itemId);
|
|
752
|
+
if (item === void 0) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`);
|
|
753
|
+
item.checked = false;
|
|
754
|
+
delete item.checkedBy;
|
|
755
|
+
delete item.checkedAt;
|
|
756
|
+
delete item.note;
|
|
757
|
+
} else throw new ToolError(ERR.invalidInput, `action must be add | check | uncheck (got "${args.action}")`);
|
|
758
|
+
if (checklist.length > 0) next.checklist = checklist;
|
|
759
|
+
else delete next.checklist;
|
|
760
|
+
next.version = task.version + 1;
|
|
761
|
+
next.updatedAt = deps.now();
|
|
762
|
+
next.updatedBy = actor;
|
|
763
|
+
ledger.tasks[index] = next;
|
|
746
764
|
return [next];
|
|
747
765
|
});
|
|
748
766
|
const progress = next.checklist !== void 0 ? {
|
|
@@ -767,7 +785,7 @@ function registerTaskboardTools(ctx, deps) {
|
|
|
767
785
|
})));
|
|
768
786
|
disposers.push(register(defineTool({
|
|
769
787
|
name: "taskboard_execution_report",
|
|
770
|
-
description: "Submit the structured execution report for the task you are currently executing (summary / changed files / how you verified / artifacts / remaining risk). Submit BEFORE moving the task to in_review; a later submission overwrites the previous report. Commits and diffs are host-collected
|
|
788
|
+
description: "Submit the structured execution report for the task you are currently executing (summary / changed files / how you verified / artifacts / remaining risk). Submit BEFORE moving the task to in_review; a later submission overwrites the previous report. If your run already settled, you may back-submit onto your latest succeeded execution while you still hold the task. Commits and diffs are host-collected.",
|
|
771
789
|
parameters: {
|
|
772
790
|
summary: {
|
|
773
791
|
type: "string",
|
|
@@ -825,7 +843,20 @@ function registerTaskboardTools(ctx, deps) {
|
|
|
825
843
|
}
|
|
826
844
|
}
|
|
827
845
|
});
|
|
828
|
-
if (taskId === void 0 || executionId === void 0)
|
|
846
|
+
if (taskId === void 0 || executionId === void 0) await store.mutate("execution-recorded", (ledger) => {
|
|
847
|
+
for (const task of ledger.tasks) {
|
|
848
|
+
if (task.trashedAt !== void 0 || task.status === "archived") continue;
|
|
849
|
+
const last = task.executions[task.executions.length - 1];
|
|
850
|
+
const owned = last !== void 0 && last.sessionId === sessionId && last.outcome === "succeeded";
|
|
851
|
+
const holds = task.claimedBy === sessionId && last !== void 0 && last.sessionId === sessionId;
|
|
852
|
+
if (!owned && !holds) continue;
|
|
853
|
+
last.report = report;
|
|
854
|
+
taskId = task.id;
|
|
855
|
+
executionId = last.id;
|
|
856
|
+
return [task];
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
if (taskId === void 0 || executionId === void 0) throw new ToolError(ERR.forbidden, "no running execution and no settled execution of yours to report on — reports attach to your running execution, or back-submit onto your latest succeeded one while you hold the task");
|
|
829
860
|
return json({
|
|
830
861
|
taskId,
|
|
831
862
|
executionId,
|
|
@@ -839,6 +870,6 @@ function registerTaskboardTools(ctx, deps) {
|
|
|
839
870
|
return disposers;
|
|
840
871
|
}
|
|
841
872
|
//#endregion
|
|
842
|
-
export { ERR, registerTaskboardTools, workspaceFace };
|
|
873
|
+
export { ERR, ToolError, registerTaskboardTools, workspaceFace };
|
|
843
874
|
|
|
844
875
|
//# sourceMappingURL=tools.js.map
|