@linxin666/dsh-client-ui-task-board 0.3.6 → 0.3.9
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.i18n.yaml +2 -2
- package/README.md +9 -4
- package/README.zh.md +9 -4
- package/lib/client.js +688 -66
- package/lib/client.js.map +1 -1
- package/lib/index.js +698 -190
- package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -1
- package/lib/types/client/board/TaskBoard.d.ts +3 -0
- package/lib/types/client/host-api.d.ts +2 -2
- package/lib/types/client/index.d.ts +10 -2
- package/lib/types/client/locales.d.ts +19 -0
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/core/controller.d.ts +11 -2
- package/lib/types/core/freeze-snapshot.d.ts +71 -0
- package/lib/types/core/handover.d.ts +58 -0
- package/lib/types/core/tasks.d.ts +70 -1
- package/lib/types/core/use-cases/task-update.d.ts +16 -7
- package/lib/types/host-ledger.d.ts +17 -2
- package/lib/types/host-runner.d.ts +43 -23
- package/lib/types/host-service.d.ts +8 -5
- package/lib/types/index.d.ts +9 -1
- package/lib/types/protocol.d.ts +15 -2
- package/package.json +26 -17
- package/src/client/TaskBoardSettingsCard.tsx +2 -1
- package/src/client/board/NewTaskModal.tsx +52 -0
- package/src/client/board/TaskBoard.tsx +5 -3
- package/src/client/board/TaskCard.tsx +3 -0
- package/src/client/board/TaskDetail.tsx +54 -0
- package/src/client/host-api.ts +5 -5
- package/src/client/index.ts +67 -7
- package/src/client/locales.ts +40 -2
- package/src/client/settings-form.ts +78 -92
- package/src/core/controller.ts +35 -10
- package/src/core/freeze-snapshot.ts +213 -0
- package/src/core/handover.ts +117 -0
- package/src/core/store.ts +48 -1
- package/src/core/tasks.ts +87 -0
- package/src/core/use-cases/task-update.ts +30 -10
- package/src/dsh-home.ts +1 -1
- package/src/host-ledger.ts +137 -59
- package/src/host-routes.ts +1 -1
- package/src/host-runner.ts +211 -97
- package/src/host-service.ts +14 -10
- package/src/index.ts +24 -10
- package/src/protocol.ts +98 -14
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Freeze-request parser and security gate: parses the system-prompt-agreed
|
|
3
|
+
* freeze format into a structured context snapshot (goal / progress / next),
|
|
4
|
+
* redacts sensitive patterns to markers, rejects slash-prefixed DSH command
|
|
5
|
+
* lines, and enforces the 8 KiB per-field byte limit.
|
|
6
|
+
* Pure functions only: no session, network, or filesystem access.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Hard per-field limit: 8 KiB measured in UTF-8 bytes. */
|
|
10
|
+
export const FREEZE_FIELD_BYTE_LIMIT = 8 * 1024
|
|
11
|
+
|
|
12
|
+
/** Marker replacing every sensitive match. */
|
|
13
|
+
export const REDACTED_MARKER = '[REDACTED]'
|
|
14
|
+
|
|
15
|
+
/** Structured context snapshot carried by a continuation card. */
|
|
16
|
+
export interface FreezeSnapshot {
|
|
17
|
+
goal: string
|
|
18
|
+
progress: string
|
|
19
|
+
next: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Non-fatal notices produced by the security gate. */
|
|
23
|
+
export type FreezeWarning = 'redacted'
|
|
24
|
+
|
|
25
|
+
export type FreezeResult =
|
|
26
|
+
| { ok: true; snapshot: FreezeSnapshot; warnings: FreezeWarning[] }
|
|
27
|
+
| { ok: false; error: { code: string; message: string } }
|
|
28
|
+
|
|
29
|
+
const BEGIN = '<<<FREEZE'
|
|
30
|
+
const END = '>>>FREEZE'
|
|
31
|
+
|
|
32
|
+
const SECTION_KEYS: ReadonlyMap<string, SectionKey> = new Map([
|
|
33
|
+
['目标:', 'goal'],
|
|
34
|
+
['进度:', 'progress'],
|
|
35
|
+
['下一步:', 'next'],
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
type SectionKey = 'goal' | 'progress' | 'next'
|
|
39
|
+
|
|
40
|
+
const SECTION_ORDER: readonly SectionKey[] = ['goal', 'progress', 'next']
|
|
41
|
+
|
|
42
|
+
const SECTION_NAMES: Record<SectionKey, string> = {
|
|
43
|
+
goal: '目标',
|
|
44
|
+
progress: '进度',
|
|
45
|
+
next: '下一步',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Sensitive patterns, each matched globally over every field body:
|
|
50
|
+
* PEM private key blocks (whole block collapses to one marker), Bearer
|
|
51
|
+
* credentials, OpenAI sk-, GitHub ghp_, GitLab glpat-, Slack xox* tokens,
|
|
52
|
+
* and AWS access key ids.
|
|
53
|
+
*/
|
|
54
|
+
const SENSITIVE_PATTERNS: RegExp[] = [
|
|
55
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
56
|
+
/Bearer\s+[A-Za-z0-9._~+\/=:-]{8,}/gi,
|
|
57
|
+
/sk-[A-Za-z0-9_-]{8,}/g,
|
|
58
|
+
/ghp_[A-Za-z0-9]{20,}/g,
|
|
59
|
+
/glpat-[A-Za-z0-9_-]{15,}/g,
|
|
60
|
+
/xox[bpars]-[A-Za-z0-9-]{10,}/g,
|
|
61
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
function fail(code: string, message: string): FreezeResult {
|
|
65
|
+
return { ok: false, error: { code, message } }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function utf8Bytes(text: string): number {
|
|
69
|
+
return new TextEncoder().encode(text).length
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Redact sensitive patterns to the marker; reports whether any hit occurred. */
|
|
73
|
+
export function redactSensitive(text: string): { text: string; redacted: boolean } {
|
|
74
|
+
let out = text
|
|
75
|
+
let redacted = false
|
|
76
|
+
for (const pattern of SENSITIVE_PATTERNS) {
|
|
77
|
+
if (pattern.test(out)) {
|
|
78
|
+
redacted = true
|
|
79
|
+
out = out.replace(pattern, REDACTED_MARKER)
|
|
80
|
+
}
|
|
81
|
+
pattern.lastIndex = 0
|
|
82
|
+
}
|
|
83
|
+
return { text: out, redacted }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* True when any line of the text starts with "/" (a DSH command line).
|
|
88
|
+
* Leading horizontal whitespace before the slash counts too: a frozen
|
|
89
|
+
* body line like ` /kill` is still a command, not prose.
|
|
90
|
+
*/
|
|
91
|
+
export function hasSlashCommandLines(text: string): boolean {
|
|
92
|
+
return text.split(/\r?\n/).some(line => /^[ \t]+\//.test(line) || line.startsWith('/'))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Sanitize a structured freeze snapshot (the create/update action payload):
|
|
97
|
+
* shape check, slash-command taint rejection, sensitive redaction, and the
|
|
98
|
+
* per-field byte cap - the same gate parseFreezeRequest applies to
|
|
99
|
+
* free-text freeze requests, exposed for the action data plane (issue #4).
|
|
100
|
+
* @param value - the freeze object carried by an action or read back from disk.
|
|
101
|
+
* @param extraKeys - keys allowed alongside goal/progress/next (e.g. the
|
|
102
|
+
* protocol redacted flag, the ledger frozenAt stamp) and preserved verbatim
|
|
103
|
+
* when present; their validation stays with the caller.
|
|
104
|
+
*/
|
|
105
|
+
export function sanitizeFreezeSnapshot(
|
|
106
|
+
value: unknown,
|
|
107
|
+
extraKeys: readonly string[] = [],
|
|
108
|
+
): { ok: true; snapshot: FreezeSnapshot; redacted: boolean; extras: Record<string, unknown> } | { ok: false; error: { code: string; message: string } } {
|
|
109
|
+
const bad = (code: string, message: string) => ({ ok: false as const, error: { code, message } })
|
|
110
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
111
|
+
return bad('invalid-freeze', '冻结快照必须是 goal/progress/next 字符串对象')
|
|
112
|
+
}
|
|
113
|
+
const record = value as Record<string, unknown>
|
|
114
|
+
const allowed = ['goal', 'progress', 'next', ...extraKeys]
|
|
115
|
+
if (!Object.keys(record).every(key => allowed.includes(key))) {
|
|
116
|
+
return bad('invalid-freeze', '冻结快照包含未知字段')
|
|
117
|
+
}
|
|
118
|
+
for (const key of SECTION_ORDER) {
|
|
119
|
+
if (typeof record[key] !== 'string') {
|
|
120
|
+
return bad('invalid-freeze', '冻结快照字段 ' + SECTION_NAMES[key] + ' 必须是字符串')
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const key of SECTION_ORDER) {
|
|
124
|
+
if (hasSlashCommandLines(record[key] as string)) {
|
|
125
|
+
return bad('dsh-command-line', '冻结文本的' + SECTION_NAMES[key] + '包含以 / 开头的命令行,整体拒绝')
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
let redacted = false
|
|
129
|
+
const snapshot: Record<SectionKey, string> = { goal: '', progress: '', next: '' }
|
|
130
|
+
for (const key of SECTION_ORDER) {
|
|
131
|
+
const result = redactSensitive(record[key] as string)
|
|
132
|
+
snapshot[key] = result.text
|
|
133
|
+
redacted = redacted || result.redacted
|
|
134
|
+
}
|
|
135
|
+
for (const key of SECTION_ORDER) {
|
|
136
|
+
if (utf8Bytes(snapshot[key]) > FREEZE_FIELD_BYTE_LIMIT) {
|
|
137
|
+
return bad('field-too-large', '冻结快照字段 ' + SECTION_NAMES[key] + ' 超过 8 KiB 上限')
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const extras: Record<string, unknown> = {}
|
|
141
|
+
for (const key of extraKeys) if (key in record) extras[key] = record[key]
|
|
142
|
+
return { ok: true, snapshot: { goal: snapshot.goal, progress: snapshot.progress, next: snapshot.next }, redacted, extras }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parse the freeze-request format: a <<<FREEZE ... >>>FREEZE block whose body
|
|
147
|
+
* is 目标: / 进度: / 下一步: section headers, each followed by body lines.
|
|
148
|
+
* The gate applies before returning: slash-command taint rejects the whole
|
|
149
|
+
* request, sensitive patterns are redacted to markers, and each field is
|
|
150
|
+
* capped at FREEZE_FIELD_BYTE_LIMIT UTF-8 bytes.
|
|
151
|
+
*/
|
|
152
|
+
export function parseFreezeRequest(input: string): FreezeResult {
|
|
153
|
+
const beginIndex = input.indexOf(BEGIN)
|
|
154
|
+
if (beginIndex === -1) {
|
|
155
|
+
return fail('missing-block', '未找到冻结请求块(需要 <<<FREEZE ... >>>FREEZE)')
|
|
156
|
+
}
|
|
157
|
+
const bodyStart = beginIndex + BEGIN.length
|
|
158
|
+
const endIndex = input.indexOf(END, bodyStart)
|
|
159
|
+
if (endIndex === -1) {
|
|
160
|
+
return fail('unterminated-block', '冻结请求块未闭合(缺少 >>>FREEZE)')
|
|
161
|
+
}
|
|
162
|
+
const block = input.slice(bodyStart, endIndex)
|
|
163
|
+
|
|
164
|
+
const raw = new Map<SectionKey, string[]>()
|
|
165
|
+
let current: SectionKey | undefined
|
|
166
|
+
for (const line of block.split(/\r?\n/)) {
|
|
167
|
+
const key = SECTION_KEYS.get(line)
|
|
168
|
+
if (key !== undefined) {
|
|
169
|
+
if (raw.has(key)) {
|
|
170
|
+
return fail('duplicate-section', `重复的段落标题:${SECTION_NAMES[key]}`)
|
|
171
|
+
}
|
|
172
|
+
raw.set(key, [])
|
|
173
|
+
current = key
|
|
174
|
+
} else if (current !== undefined) {
|
|
175
|
+
raw.get(current)!.push(line)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const snapshot: Record<SectionKey, string> = { goal: '', progress: '', next: '' }
|
|
180
|
+
for (const key of SECTION_ORDER) {
|
|
181
|
+
if (!raw.has(key)) {
|
|
182
|
+
return fail('missing-section', `冻结请求缺少段落:${SECTION_NAMES[key]}`)
|
|
183
|
+
}
|
|
184
|
+
const lines = raw.get(key)!
|
|
185
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
|
|
186
|
+
snapshot[key] = lines.join('\n')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const key of SECTION_ORDER) {
|
|
190
|
+
if (hasSlashCommandLines(snapshot[key])) {
|
|
191
|
+
return fail('dsh-command-line', `冻结文本的${SECTION_NAMES[key]}包含以 / 开头的命令行,整体拒绝`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let redacted = false
|
|
196
|
+
for (const key of SECTION_ORDER) {
|
|
197
|
+
const result = redactSensitive(snapshot[key])
|
|
198
|
+
snapshot[key] = result.text
|
|
199
|
+
redacted = redacted || result.redacted
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const key of SECTION_ORDER) {
|
|
203
|
+
if (utf8Bytes(snapshot[key]) > FREEZE_FIELD_BYTE_LIMIT) {
|
|
204
|
+
return fail('field-too-large', `冻结快照字段 ${SECTION_NAMES[key]} 超过 8 KiB 上限`)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
ok: true,
|
|
210
|
+
snapshot: { goal: snapshot.goal, progress: snapshot.progress, next: snapshot.next },
|
|
211
|
+
warnings: redacted ? ['redacted'] : [],
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handover bundle (issue #5, ADR 0001): the resource reference set a
|
|
3
|
+
* continuation card carries — the pinned execution triplet (workspace /
|
|
4
|
+
* agent preset / permission) plus doc/script references — so a picking-up
|
|
5
|
+
* session can start with zero context.
|
|
6
|
+
*
|
|
7
|
+
* Security gate (adversarial scenario b): a bundle whose effective
|
|
8
|
+
* permission is HIGHER than the session default is an unconfirmed binding.
|
|
9
|
+
* It enters the pending-confirmation state (the card stores the binding
|
|
10
|
+
* without a permissionConfirmedAt stamp); manual execution refuses, cron
|
|
11
|
+
* refuses to schedule, and the human confirm-permission action resolves the
|
|
12
|
+
* transaction by stamping the confirmation. Any later permission or
|
|
13
|
+
* handover change re-arms the gate (the confirmation binds the exact
|
|
14
|
+
* permission value, blocking confirm-then-swap escalation).
|
|
15
|
+
*/
|
|
16
|
+
import { isTaskPermission, type TaskPermission, type TaskRecord } from './tasks.ts'
|
|
17
|
+
|
|
18
|
+
export type { TaskPermission }
|
|
19
|
+
|
|
20
|
+
/** Wire shape of a handover bundle (before bundledAt is stamped). */
|
|
21
|
+
export interface TaskHandoverInput {
|
|
22
|
+
workspaceId?: string
|
|
23
|
+
mode?: string
|
|
24
|
+
permission?: TaskPermission
|
|
25
|
+
references: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Persisted handover bundle on a task record. */
|
|
29
|
+
export interface TaskHandover extends TaskHandoverInput {
|
|
30
|
+
/** When the bundle was attached/replaced (ms epoch, stamped by the use case). */
|
|
31
|
+
bundledAt: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Most references a bundle may carry. */
|
|
35
|
+
export const HANDOVER_MAX_REFERENCES = 32
|
|
36
|
+
/** Per-reference UTF-8 byte cap. */
|
|
37
|
+
export const HANDOVER_MAX_REFERENCE_BYTES = 512
|
|
38
|
+
/** Total references UTF-8 byte cap. */
|
|
39
|
+
export const HANDOVER_MAX_TOTAL_BYTES = 8 * 1024
|
|
40
|
+
/** Per target-id (workspace/preset) byte cap. */
|
|
41
|
+
export const HANDOVER_MAX_TARGET_BYTES = 256
|
|
42
|
+
|
|
43
|
+
/** The board's notion of the deployment session-default permission (fail-safe default). */
|
|
44
|
+
export const DEFAULT_SESSION_PERMISSION: TaskPermission = 'read-only'
|
|
45
|
+
|
|
46
|
+
/** Permission elevation rank (higher = more authority). */
|
|
47
|
+
export const PERMISSION_RANK: ReadonlyMap<TaskPermission, number> = new Map([
|
|
48
|
+
['read-only', 0],
|
|
49
|
+
['workspace-write', 1],
|
|
50
|
+
['danger-full-access', 2],
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
function byteLength(value: string): number {
|
|
54
|
+
return new TextEncoder().encode(value).length
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Gate a handover bundle from the wire or disk: exact keys, string targets
|
|
59
|
+
* under the byte cap, a known permission, and a bounded string reference
|
|
60
|
+
* list. Returns the sanitized bundle, or undefined when rejected.
|
|
61
|
+
*/
|
|
62
|
+
export function sanitizeHandover(value: unknown): TaskHandoverInput | undefined {
|
|
63
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
|
|
64
|
+
const bundle = value as Record<string, unknown>
|
|
65
|
+
const keys = Object.keys(bundle)
|
|
66
|
+
if (!keys.every(key => ['workspaceId', 'mode', 'permission', 'references'].includes(key))) return undefined
|
|
67
|
+
if (!Array.isArray(bundle.references)) return undefined
|
|
68
|
+
if (bundle.references.length > HANDOVER_MAX_REFERENCES) return undefined
|
|
69
|
+
let total = 0
|
|
70
|
+
for (const reference of bundle.references) {
|
|
71
|
+
if (typeof reference !== 'string' || reference === '') return undefined
|
|
72
|
+
const size = byteLength(reference)
|
|
73
|
+
if (size > HANDOVER_MAX_REFERENCE_BYTES) return undefined
|
|
74
|
+
total += size
|
|
75
|
+
if (total > HANDOVER_MAX_TOTAL_BYTES) return undefined
|
|
76
|
+
}
|
|
77
|
+
for (const key of ['workspaceId', 'mode'] as const) {
|
|
78
|
+
const target: unknown = bundle[key]
|
|
79
|
+
if (target !== undefined && (typeof target !== 'string' || byteLength(target) > HANDOVER_MAX_TARGET_BYTES)) return undefined
|
|
80
|
+
}
|
|
81
|
+
if (bundle.permission !== undefined && !isTaskPermission(bundle.permission)) return undefined
|
|
82
|
+
const workspaceId = bundle.workspaceId
|
|
83
|
+
const mode = bundle.mode
|
|
84
|
+
return {
|
|
85
|
+
...(typeof workspaceId === 'string' ? { workspaceId } : {}),
|
|
86
|
+
...(typeof mode === 'string' ? { mode } : {}),
|
|
87
|
+
...(bundle.permission === undefined ? {} : { permission: bundle.permission as TaskPermission }),
|
|
88
|
+
references: [...bundle.references as string[]],
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Build the persisted bundle from a sanitized input, stamping bundledAt. */
|
|
93
|
+
export function handoverOf(input: TaskHandoverInput, now: number): TaskHandover {
|
|
94
|
+
return { ...input, bundledAt: now }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The permission an execution session would actually run under. */
|
|
98
|
+
export function effectivePermission(task: Pick<TaskRecord, 'permission' | 'handover'>): TaskPermission | undefined {
|
|
99
|
+
return task.handover?.permission ?? task.permission
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Whether a binding's permission is elevated above the session default. */
|
|
103
|
+
export function exceedsSessionDefault(permission: TaskPermission | undefined, sessionDefault: TaskPermission): boolean {
|
|
104
|
+
if (permission === undefined) return false
|
|
105
|
+
return (PERMISSION_RANK.get(permission) ?? -1) > (PERMISSION_RANK.get(sessionDefault) ?? -1)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The confirmation-gate predicate: an elevated permission without a human
|
|
110
|
+
* confirmation stamp. Manual run/rerun and cron must refuse such a card.
|
|
111
|
+
*/
|
|
112
|
+
export function requiresPermissionConfirmation(
|
|
113
|
+
task: Pick<TaskRecord, 'permission' | 'handover' | 'permissionConfirmedAt'>,
|
|
114
|
+
sessionDefault: TaskPermission = DEFAULT_SESSION_PERMISSION,
|
|
115
|
+
): boolean {
|
|
116
|
+
return exceedsSessionDefault(effectivePermission(task), sessionDefault) && task.permissionConfirmedAt === undefined
|
|
117
|
+
}
|
package/src/core/store.ts
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
* localStorage backend.
|
|
11
11
|
*/
|
|
12
12
|
import { isValidCron } from './schedule.ts'
|
|
13
|
-
import { isTaskPermission, isTaskStatus, normalizeTargetId, type ScheduleRule, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
13
|
+
import { isTaskPermission, isTaskStatus, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
14
|
+
import type { TaskHandover } from './handover.ts'
|
|
15
|
+
import { sanitizeFreezeSnapshot } from './freeze-snapshot.ts'
|
|
16
|
+
import { sanitizeHandover } from './handover.ts'
|
|
14
17
|
|
|
15
18
|
/** Persistence seam for the task ledger. */
|
|
16
19
|
export interface TaskStore {
|
|
@@ -72,6 +75,9 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
72
75
|
if (entry.endedAt !== undefined && typeof entry.endedAt !== 'number') return false
|
|
73
76
|
if (entry.result !== undefined && entry.result !== 'succeeded' && entry.result !== 'failed' && entry.result !== 'cancelled') return false
|
|
74
77
|
if (entry.error !== undefined && typeof entry.error !== 'string') return false
|
|
78
|
+
if (entry.initiatedBy !== undefined && typeof entry.initiatedBy !== 'string') return false
|
|
79
|
+
if (entry.frozenBy !== undefined && typeof entry.frozenBy !== 'string') return false
|
|
80
|
+
if (entry.frozenAt !== undefined && typeof entry.frozenAt !== 'number') return false
|
|
75
81
|
}
|
|
76
82
|
return true
|
|
77
83
|
}
|
|
@@ -107,6 +113,44 @@ function normalizeSchedule(schedule: unknown): ScheduleRule | undefined {
|
|
|
107
113
|
}
|
|
108
114
|
}
|
|
109
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Repair a persisted freeze snapshot: shape + gate re-check (slash taint,
|
|
118
|
+
* redaction idempotence, byte cap); a malformed or tainted snapshot is
|
|
119
|
+
* dropped (undefined) rather than dropping the whole task row, mirroring
|
|
120
|
+
* the schedule repair policy.
|
|
121
|
+
*/
|
|
122
|
+
function normalizeFreeze(value: unknown): TaskFreeze | undefined {
|
|
123
|
+
const result = sanitizeFreezeSnapshot(value, ['frozenAt', 'redacted', 'frozenBy'])
|
|
124
|
+
if (!result.ok) return undefined
|
|
125
|
+
const frozenAt = result.extras.frozenAt
|
|
126
|
+
if (typeof frozenAt !== 'number' || !Number.isFinite(frozenAt)) return undefined
|
|
127
|
+
if (result.extras.redacted !== undefined && result.extras.redacted !== true) return undefined
|
|
128
|
+
const frozenBy = result.extras.frozenBy
|
|
129
|
+
if (frozenBy !== undefined && (typeof frozenBy !== 'string' || frozenBy === '')) return undefined
|
|
130
|
+
return {
|
|
131
|
+
goal: result.snapshot.goal,
|
|
132
|
+
progress: result.snapshot.progress,
|
|
133
|
+
next: result.snapshot.next,
|
|
134
|
+
frozenAt,
|
|
135
|
+
...(result.redacted || result.extras.redacted === true ? { redacted: true } : {}),
|
|
136
|
+
...(frozenBy === undefined ? {} : { frozenBy }),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Repair a persisted handover bundle: shape re-check through the same gate
|
|
142
|
+
* as the wire path; a malformed bundle is dropped rather than dropping the
|
|
143
|
+
* task row (mirroring the schedule/freeze repair policy).
|
|
144
|
+
*/
|
|
145
|
+
function normalizeHandover(value: unknown): TaskHandover | undefined {
|
|
146
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
147
|
+
const { bundledAt, ...rest } = value as Record<string, unknown> & { bundledAt: unknown }
|
|
148
|
+
const bundle = sanitizeHandover(rest)
|
|
149
|
+
if (bundle === undefined) return undefined
|
|
150
|
+
if (typeof bundledAt !== 'number' || !Number.isFinite(bundledAt)) return undefined
|
|
151
|
+
return { ...bundle, bundledAt }
|
|
152
|
+
}
|
|
153
|
+
|
|
110
154
|
/** Parse + validate a persisted ledger document; invalid rows are dropped. */
|
|
111
155
|
export function parseLedger(raw: string | null): TaskRecord[] {
|
|
112
156
|
if (raw === null) return []
|
|
@@ -141,6 +185,9 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
141
185
|
task.mode = normalizeTargetId(row.mode)
|
|
142
186
|
task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
|
|
143
187
|
task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
|
|
188
|
+
task.freeze = normalizeFreeze(row.freeze)
|
|
189
|
+
task.handover = normalizeHandover(row.handover)
|
|
190
|
+
task.permissionConfirmedAt = typeof row.permissionConfirmedAt === 'number' && Number.isFinite(row.permissionConfirmedAt) ? row.permissionConfirmedAt : undefined
|
|
144
191
|
tasks.push(task)
|
|
145
192
|
}
|
|
146
193
|
return tasks
|
package/src/core/tasks.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Framework-free (no cordis, no runtime imports) so the state machine is
|
|
5
5
|
* unit-testable in isolation.
|
|
6
6
|
*/
|
|
7
|
+
import type { FreezeSnapshot } from './freeze-snapshot.ts'
|
|
8
|
+
import type { TaskHandover, TaskHandoverInput } from './handover.ts'
|
|
7
9
|
|
|
8
10
|
/** Task lifecycle status, one per kanban column. */
|
|
9
11
|
export type TaskStatus = 'backlog' | 'todo' | 'running' | 'done' | 'failed'
|
|
@@ -26,6 +28,16 @@ export interface ExecutionRecord {
|
|
|
26
28
|
result: 'succeeded' | 'failed' | 'cancelled' | undefined
|
|
27
29
|
/** Human failure text when the run failed (prompt rejection or agent error). */
|
|
28
30
|
error: string | undefined
|
|
31
|
+
/**
|
|
32
|
+
* Session id of the DSH session that issued the run/rerun action (issue #6
|
|
33
|
+
* audit origin). Client-asserted, not a trust boundary; absent when the run
|
|
34
|
+
* was triggered by cron (source unknown).
|
|
35
|
+
*/
|
|
36
|
+
initiatedBy?: string
|
|
37
|
+
/** Freeze instant captured from the card snapshot when the run opened. */
|
|
38
|
+
frozenAt?: number
|
|
39
|
+
/** Freeze source session captured from the card snapshot when the run opened. */
|
|
40
|
+
frozenBy?: string
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
/**
|
|
@@ -64,6 +76,24 @@ export interface ScheduleRule {
|
|
|
64
76
|
lastTriggeredAt: number | undefined
|
|
65
77
|
}
|
|
66
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Frozen context snapshot carried by a continuation card (issue #4): the
|
|
81
|
+
* goal/progress/next text (already sanitized by the freeze gates) plus the
|
|
82
|
+
* freeze instant and the redaction warning flag.
|
|
83
|
+
*/
|
|
84
|
+
export interface TaskFreeze extends FreezeSnapshot {
|
|
85
|
+
/** When the snapshot was frozen (ms epoch, stamped by the create/update use case). */
|
|
86
|
+
frozenAt: number
|
|
87
|
+
/** True when the freeze gates redacted sensitive patterns out of the text. */
|
|
88
|
+
redacted?: boolean
|
|
89
|
+
/**
|
|
90
|
+
* Session id of the DSH session that authored the frozen snapshot (issue #6
|
|
91
|
+
* provenance): stamped by the Host ledger from the create/update action's
|
|
92
|
+
* initiator, or kept from the wire payload when no initiator was asserted.
|
|
93
|
+
*/
|
|
94
|
+
frozenBy?: string
|
|
95
|
+
}
|
|
96
|
+
|
|
67
97
|
/** One task on the board. */
|
|
68
98
|
export interface TaskRecord {
|
|
69
99
|
/** Stable task id (uuid). */
|
|
@@ -102,6 +132,25 @@ export interface TaskRecord {
|
|
|
102
132
|
* `/permission <id>` slash command; absent leaves the session default.
|
|
103
133
|
*/
|
|
104
134
|
permission?: TaskPermission
|
|
135
|
+
/**
|
|
136
|
+
* Frozen context snapshot for a continuation card; absent on plain tasks.
|
|
137
|
+
* Sanitized before it enters the ledger (redaction, slash-command taint,
|
|
138
|
+
* 8 KiB per-field cap) by the protocol gate and re-normalized on load.
|
|
139
|
+
*/
|
|
140
|
+
freeze?: TaskFreeze
|
|
141
|
+
/**
|
|
142
|
+
* Handover bundle carried by a continuation card (issue #5): the pinned
|
|
143
|
+
* execution triplet plus doc/script references. Sanitized before it
|
|
144
|
+
* enters the ledger by the protocol gate and re-normalized on load; the
|
|
145
|
+
* bundle's triplet overrides the legacy pin fields at execution time.
|
|
146
|
+
*/
|
|
147
|
+
handover?: TaskHandover
|
|
148
|
+
/**
|
|
149
|
+
* Human confirmation stamp for an above-default effective permission
|
|
150
|
+
* (ms epoch). Absent while the binding awaits confirmation; any permission
|
|
151
|
+
* or handover change re-arms the gate by clearing it.
|
|
152
|
+
*/
|
|
153
|
+
permissionConfirmedAt?: number
|
|
105
154
|
/**
|
|
106
155
|
* When the task was archived (ms epoch). Archived tasks keep their status
|
|
107
156
|
* and execution history, leave the main board, and cannot run until restored;
|
|
@@ -142,6 +191,16 @@ export interface NewTaskInput {
|
|
|
142
191
|
* case arms it only when enabled and the expression is valid.
|
|
143
192
|
*/
|
|
144
193
|
schedule?: { enabled: boolean; cron: string }
|
|
194
|
+
/**
|
|
195
|
+
* Optional frozen context snapshot (goal/progress/next, sanitized by the
|
|
196
|
+
* protocol gate) turning the new task into a continuation card.
|
|
197
|
+
*/
|
|
198
|
+
freeze?: FreezeSnapshot & { redacted?: boolean; frozenBy?: string }
|
|
199
|
+
/**
|
|
200
|
+
* Optional handover bundle (pinned triplet + doc/script references,
|
|
201
|
+
* sanitized by the protocol gate) attached at creation.
|
|
202
|
+
*/
|
|
203
|
+
handover?: TaskHandoverInput
|
|
145
204
|
}
|
|
146
205
|
|
|
147
206
|
/** The five kanban columns, in display order. */
|
|
@@ -180,6 +239,24 @@ export function normalizeTargetId(value: string | undefined): string | undefined
|
|
|
180
239
|
return trimmed === undefined || trimmed === '' ? undefined : trimmed
|
|
181
240
|
}
|
|
182
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Build the persisted freeze snapshot from a sanitized input, stamping the
|
|
244
|
+
* freeze instant (shared by the create and update use cases).
|
|
245
|
+
*/
|
|
246
|
+
export function freezeOf(
|
|
247
|
+
input: FreezeSnapshot & { redacted?: boolean; frozenBy?: string },
|
|
248
|
+
now: number,
|
|
249
|
+
): TaskFreeze {
|
|
250
|
+
return {
|
|
251
|
+
goal: input.goal,
|
|
252
|
+
progress: input.progress,
|
|
253
|
+
next: input.next,
|
|
254
|
+
frozenAt: now,
|
|
255
|
+
...(input.redacted === true ? { redacted: true } : {}),
|
|
256
|
+
...(input.frozenBy === undefined || input.frozenBy === '' ? {} : { frozenBy: input.frozenBy }),
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
183
260
|
/** Create a task from user input. */
|
|
184
261
|
export function createTask(input: NewTaskInput, now: number, id: string): TaskRecord {
|
|
185
262
|
return {
|
|
@@ -194,6 +271,8 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
|
|
|
194
271
|
workspaceId: normalizeTargetId(input.workspaceId),
|
|
195
272
|
mode: normalizeTargetId(input.mode),
|
|
196
273
|
permission: isTaskPermission(input.permission) ? input.permission : undefined,
|
|
274
|
+
...(input.freeze === undefined ? {} : { freeze: freezeOf(input.freeze, now) }),
|
|
275
|
+
...(input.handover === undefined ? {} : { handover: { ...input.handover, bundledAt: now } }),
|
|
197
276
|
}
|
|
198
277
|
}
|
|
199
278
|
|
|
@@ -235,6 +314,7 @@ export function startExecution(
|
|
|
235
314
|
task: TaskRecord,
|
|
236
315
|
now: number,
|
|
237
316
|
executionId: string,
|
|
317
|
+
initiatedBy?: string,
|
|
238
318
|
): { task: TaskRecord; execution: ExecutionRecord } {
|
|
239
319
|
const execution: ExecutionRecord = {
|
|
240
320
|
id: executionId,
|
|
@@ -243,6 +323,13 @@ export function startExecution(
|
|
|
243
323
|
endedAt: undefined,
|
|
244
324
|
result: undefined,
|
|
245
325
|
error: undefined,
|
|
326
|
+
...(initiatedBy === undefined || initiatedBy === '' ? {} : { initiatedBy }),
|
|
327
|
+
// Capture the card's freeze provenance on the execution record so the
|
|
328
|
+
// audit trail stays queryable even if the snapshot is replaced later.
|
|
329
|
+
...(task.freeze === undefined ? {} : {
|
|
330
|
+
frozenAt: task.freeze.frozenAt,
|
|
331
|
+
...(task.freeze.frozenBy === undefined ? {} : { frozenBy: task.freeze.frozenBy }),
|
|
332
|
+
}),
|
|
246
333
|
}
|
|
247
334
|
return {
|
|
248
335
|
task: {
|
|
@@ -4,18 +4,25 @@
|
|
|
4
4
|
* fresh updatedAt. Pure ledger transition (no persistence or notify — the
|
|
5
5
|
* controller orchestrates those).
|
|
6
6
|
*
|
|
7
|
-
* An explicit `undefined` in the patch clears
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* can never persist a value the execution service rejects.
|
|
7
|
+
* An explicit `undefined` in the patch clears the field (the task falls
|
|
8
|
+
* back to the runtime default); an unknown permission string is ignored so
|
|
9
|
+
* stale UI can never persist a value the execution service rejects.
|
|
11
10
|
*/
|
|
12
|
-
import { isTaskPermission, normalizeTargetId, type TaskRecord, type TaskPermission } from '../tasks.ts'
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission'>>
|
|
11
|
+
import { freezeOf, isTaskPermission, normalizeTargetId, type TaskRecord, type TaskPermission } from '../tasks.ts'
|
|
12
|
+
import type { FreezeSnapshot } from '../freeze-snapshot.ts'
|
|
13
|
+
import type { TaskHandoverInput } from '../handover.ts'
|
|
16
14
|
|
|
17
15
|
/**
|
|
18
|
-
*
|
|
16
|
+
* Editable fields on a task (the update patch surface). `freeze` replaces the
|
|
17
|
+
* continuation-card snapshot (restamping frozenAt); an explicit null clears it.
|
|
18
|
+
*/
|
|
19
|
+
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission'>> & {
|
|
20
|
+
freeze?: FreezeSnapshot & { redacted?: boolean } | null
|
|
21
|
+
/** Replaces the handover bundle (restamping bundledAt); an explicit null clears it. */
|
|
22
|
+
handover?: TaskHandoverInput | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The fields that edit the task's content (what the user reads and what the
|
|
19
26
|
* next execution sends). Unlike the execution targets they stay editable only
|
|
20
27
|
* while the task has never started executing — after the first run the
|
|
21
28
|
* recorded prompt is the record of what actually ran, so it becomes read-only.
|
|
@@ -61,10 +68,11 @@ export function applyUpdateTask(
|
|
|
61
68
|
): readonly TaskRecord[] {
|
|
62
69
|
return tasks.map(task => {
|
|
63
70
|
if (task.id !== id) return task
|
|
71
|
+
const { freeze: freezePatch, handover: handoverPatch, ...rest } = patch
|
|
64
72
|
const workspaceId = 'workspaceId' in patch ? normalizeTargetId(patch.workspaceId) : undefined
|
|
65
73
|
const mode = 'mode' in patch ? normalizeTargetId(patch.mode) : undefined
|
|
66
74
|
const permission = 'permission' in patch ? normalizePermission(task.permission, patch.permission) : undefined
|
|
67
|
-
const next: TaskRecord = { ...task, ...
|
|
75
|
+
const next: TaskRecord = { ...task, ...rest, updatedAt: now }
|
|
68
76
|
// Content fields normalize like creation does (trimmed); an explicit
|
|
69
77
|
// undefined keeps the current value — content cannot be cleared.
|
|
70
78
|
for (const field of TASK_CONTENT_FIELDS) {
|
|
@@ -72,6 +80,18 @@ export function applyUpdateTask(
|
|
|
72
80
|
const value = patch[field]
|
|
73
81
|
next[field] = value === undefined ? task[field] : value.trim()
|
|
74
82
|
}
|
|
83
|
+
// null (or a vanished key value) clears the snapshot; a present object
|
|
84
|
+
// replaces it with a fresh frozenAt stamp.
|
|
85
|
+
next.freeze = freezePatch == null ? undefined : freezeOf(freezePatch, now)
|
|
86
|
+
// Handover follows the same null-clears convention, restamping bundledAt.
|
|
87
|
+
next.handover = handoverPatch == null ? undefined : { ...handoverPatch, bundledAt: now }
|
|
88
|
+
// The permission confirmation binds the exact permission value: a change
|
|
89
|
+
// of the pinned permission or of the handover bundle re-arms the gate
|
|
90
|
+
// (blocking confirm-then-swap escalation). handover was destructured out
|
|
91
|
+
// of rest above, so the stamp survives only when explicitly kept.
|
|
92
|
+
if (('permission' in patch && patch.permission !== undefined && patch.permission !== task.permission) || 'handover' in patch) {
|
|
93
|
+
next.permissionConfirmedAt = undefined
|
|
94
|
+
}
|
|
75
95
|
if (workspaceId !== undefined || 'workspaceId' in patch) next.workspaceId = workspaceId
|
|
76
96
|
if (mode !== undefined || 'mode' in patch) next.mode = mode
|
|
77
97
|
if (permission !== undefined || 'permission' in patch) next.permission = permission
|
package/src/dsh-home.ts
CHANGED