@zhchxiao123/dsh-devflow 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +56 -0
- package/README.zh.md +56 -0
- package/lib/index.js +325 -0
- package/lib/invariant.js +42 -0
- package/lib/types/client.d.ts +10 -0
- package/lib/types/client.js +10 -0
- package/lib/types/index.d.ts +155 -0
- package/lib/types/index.js +104 -0
- package/lib/types/invariant.d.ts +13 -0
- package/lib/types/invariant.js +51 -0
- package/lib/types/journal.d.ts +46 -0
- package/lib/types/journal.js +195 -0
- package/lib/types/stages.d.ts +52 -0
- package/lib/types/stages.js +82 -0
- package/lib/types/types.d.ts +303 -0
- package/lib/types/types.js +8 -0
- package/package.json +61 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Definition of the `ctx.devflow` capability seam: file-backed task
|
|
3
|
+
* cards whose stage moves through a fixed pipeline. This package owns the card
|
|
4
|
+
* vocabulary and the journal decode/replay used by every consumer. Storage
|
|
5
|
+
* mechanics belong to a provider such as `@zhchxiao123/dsh-devflow-filesystem`;
|
|
6
|
+
* model-facing tools belong to `@zhchxiao123/dsh-devflow-tool`.
|
|
7
|
+
* @module @zhchxiao123/dsh-devflow
|
|
8
|
+
*/
|
|
9
|
+
import { Context, Service } from '@deepseek-ai/cordis';
|
|
10
|
+
import type { ArtifactRequest, ArtifactResult, CardFilter, ClaimHolder, ClaimOptions, ClaimResult, CreateRequest, CreateResult, CreateSpec, DevActor, DevCard, DevCardDetail, DevflowCardId, DevflowJournalEntry, TransitionRequest, TransitionResult, TransitionSpec } from './types.ts';
|
|
11
|
+
export type * from './types.ts';
|
|
12
|
+
export { DEV_STAGES, DevflowCardId, isCardLocation, isDevStage, isLegalTransition, isReworkEdge } from './stages.ts';
|
|
13
|
+
export { decodeJournalEntry, foldJournal } from './journal.ts';
|
|
14
|
+
export type { JournalFoldState } from './journal.ts';
|
|
15
|
+
declare module '@deepseek-ai/cordis' {
|
|
16
|
+
interface Context {
|
|
17
|
+
devflow: DevflowStore;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Abstract task-card store registered as `ctx.devflow` (one implementation per
|
|
22
|
+
* context; loading a second throws, cordis' standard duplicate-service
|
|
23
|
+
* behavior). Subclass, implement the abstract methods, and load the subclass
|
|
24
|
+
* as a plugin.
|
|
25
|
+
*
|
|
26
|
+
* Implementations must honor these read-side semantics:
|
|
27
|
+
* - Current state comes from journal replay ({@link foldJournal}); the card
|
|
28
|
+
* file's frontmatter is a projection. On disagreement the journal wins and
|
|
29
|
+
* the drift is warned, never silently adopted.
|
|
30
|
+
* - A structurally invalid journal fails the read loudly, naming the file and
|
|
31
|
+
* line; a card is never silently skipped.
|
|
32
|
+
*/
|
|
33
|
+
export declare abstract class DevflowStore extends Service {
|
|
34
|
+
constructor(ctx: Context);
|
|
35
|
+
/**
|
|
36
|
+
* List the cards in the active set of one root.
|
|
37
|
+
* @param filter - optional narrowing; omitted lists every card.
|
|
38
|
+
* @param root - devflow root to list; omitted uses the implementation's default root.
|
|
39
|
+
* @returns cards ordered by id.
|
|
40
|
+
*/
|
|
41
|
+
abstract list(filter?: CardFilter, root?: string): Promise<DevCard[]>;
|
|
42
|
+
/**
|
|
43
|
+
* Read one card.
|
|
44
|
+
* @param id - the card id (its directory name).
|
|
45
|
+
* @param root - devflow root holding the card; omitted uses the implementation's default root.
|
|
46
|
+
* @returns the card with journal-derived current state.
|
|
47
|
+
*/
|
|
48
|
+
abstract read(id: DevflowCardId, root?: string): Promise<DevCard>;
|
|
49
|
+
/**
|
|
50
|
+
* Read one card's complete decoded journal, in revision order. The stream
|
|
51
|
+
* is validated like a read: a structurally invalid journal fails loudly,
|
|
52
|
+
* naming the file and line.
|
|
53
|
+
* @param id - the card id (its directory name).
|
|
54
|
+
* @param root - devflow root holding the card; omitted uses the implementation's default root.
|
|
55
|
+
* @returns the decoded entries, oldest first.
|
|
56
|
+
*/
|
|
57
|
+
abstract history(id: DevflowCardId, root?: string): Promise<DevflowJournalEntry[]>;
|
|
58
|
+
/**
|
|
59
|
+
* Read the card's current lease holder.
|
|
60
|
+
* @param id - the card id (its directory name).
|
|
61
|
+
* @param root - devflow root holding the card; omitted uses the implementation's default root.
|
|
62
|
+
* @returns the holder facts, or `undefined` while the card is unclaimed; a
|
|
63
|
+
* corrupt claim record fails loudly.
|
|
64
|
+
*/
|
|
65
|
+
abstract holder(id: DevflowCardId, root?: string): Promise<ClaimHolder | undefined>;
|
|
66
|
+
/**
|
|
67
|
+
* Apply implementation-owned defaults to a creation request: the slug when
|
|
68
|
+
* omitted, the devflow root when omitted, and the creation timestamp.
|
|
69
|
+
* @param request - the caller's request.
|
|
70
|
+
* @returns the fully specified spec to hand to {@link create}.
|
|
71
|
+
*/
|
|
72
|
+
abstract resolveCreate(request: CreateRequest): CreateSpec;
|
|
73
|
+
/**
|
|
74
|
+
* Create one card in the active set: sequence-number allocation, the
|
|
75
|
+
* exclusive card-directory creation, the journal's first `created` entry
|
|
76
|
+
* (the only commit point), the projection write, then `devflow/card-created`.
|
|
77
|
+
* Sequence numbers continue past archived cards, so an id is never reissued.
|
|
78
|
+
* @param spec - a resolved spec from {@link resolveCreate}, never a raw request.
|
|
79
|
+
* @returns the outcome; domain rejections resolve with `ok: false`.
|
|
80
|
+
*/
|
|
81
|
+
abstract create(spec: CreateSpec): Promise<CreateResult>;
|
|
82
|
+
/**
|
|
83
|
+
* Apply implementation-owned defaults to a transition request: the devflow
|
|
84
|
+
* root when omitted and the commit timestamp.
|
|
85
|
+
* @param request - the caller's request.
|
|
86
|
+
* @returns the fully specified spec to hand to {@link transition}.
|
|
87
|
+
*/
|
|
88
|
+
abstract resolve(request: TransitionRequest): TransitionSpec;
|
|
89
|
+
/**
|
|
90
|
+
* Commit one stage move: revision check, edge check, the
|
|
91
|
+
* `devflow/transition` waterfall, the journal append (the only commit
|
|
92
|
+
* point), the projection rewrite, then `devflow/stage-changed`. State and
|
|
93
|
+
* notifications publish only after the journal committed.
|
|
94
|
+
* @param spec - a resolved spec from {@link resolve}, never a raw request.
|
|
95
|
+
* @returns the outcome; domain rejections resolve with `ok: false`.
|
|
96
|
+
*/
|
|
97
|
+
abstract transition(spec: TransitionSpec): Promise<TransitionResult>;
|
|
98
|
+
/**
|
|
99
|
+
* Take the card's exclusive lease.
|
|
100
|
+
* @param id - the card to claim.
|
|
101
|
+
* @param owner - the prospective holder, recorded in the lease.
|
|
102
|
+
* @param options - staleness takeover policy and root; omitted never takes
|
|
103
|
+
* over and uses the implementation's default root.
|
|
104
|
+
* @returns the live handle, or the current holder when the lease is taken.
|
|
105
|
+
*/
|
|
106
|
+
abstract claim(id: DevflowCardId, owner: DevActor, options?: ClaimOptions): Promise<ClaimResult>;
|
|
107
|
+
/**
|
|
108
|
+
* Register a stage deliverable in the card's journal against its current
|
|
109
|
+
* stage. A blocked card cannot register artifacts, and the revision check
|
|
110
|
+
* mirrors {@link transition}.
|
|
111
|
+
* @param request - card, artifact path, expected revision, and actor.
|
|
112
|
+
* @returns the outcome; domain rejections resolve with `ok: false`.
|
|
113
|
+
*/
|
|
114
|
+
abstract attachArtifact(request: ArtifactRequest): Promise<ArtifactResult>;
|
|
115
|
+
/**
|
|
116
|
+
* Move every `done` card of one root out of the active set into that root's
|
|
117
|
+
* archive, keyed by the month of its last journal entry. Archived cards
|
|
118
|
+
* leave {@link list} but keep their complete journal.
|
|
119
|
+
* @param root - devflow root to archive; omitted uses the implementation's default root.
|
|
120
|
+
* @returns the archived card ids, in id order.
|
|
121
|
+
*/
|
|
122
|
+
abstract archiveDone(root?: string): Promise<DevflowCardId[]>;
|
|
123
|
+
/**
|
|
124
|
+
* {@link list} scoped to a viewing session's workspace, the face every
|
|
125
|
+
* browser channel reads through.
|
|
126
|
+
* @param filter - optional narrowing; omitted lists every card.
|
|
127
|
+
* @param sessionId - the viewing session; its workspace resolves host-side
|
|
128
|
+
* to the devflow root, so the wire never carries a file path. Omitted
|
|
129
|
+
* lists the default root.
|
|
130
|
+
* @returns cards ordered by id.
|
|
131
|
+
*/
|
|
132
|
+
listForSession(filter?: CardFilter, sessionId?: string): Promise<DevCard[]>;
|
|
133
|
+
/**
|
|
134
|
+
* One card's detail scoped to a viewing session's workspace: the read value,
|
|
135
|
+
* its complete decoded journal, and the current lease holder in one round
|
|
136
|
+
* trip.
|
|
137
|
+
* @param id - the card id (its directory name).
|
|
138
|
+
* @param sessionId - the viewing session; resolved like {@link listForSession}.
|
|
139
|
+
* @returns the aggregated detail; `holder` is absent while the card is unclaimed.
|
|
140
|
+
*/
|
|
141
|
+
detailForSession(id: DevflowCardId, sessionId?: string): Promise<DevCardDetail>;
|
|
142
|
+
/**
|
|
143
|
+
* Resolve a viewing session into its workspace devflow root: the live or
|
|
144
|
+
* persisted session's header cwd maps to `<cwd>/.devflow`, and a session
|
|
145
|
+
* without a cwd derives no root (the implementation default applies). The
|
|
146
|
+
* browser sends only the session id — this host-side step is what keeps
|
|
147
|
+
* a root off the wire the browser can choose.
|
|
148
|
+
* @param sessionId - the viewing session, or `undefined` for the default root.
|
|
149
|
+
* @returns the derived root, or `undefined` when none derives.
|
|
150
|
+
* @throws {Error} for an unknown session, or when no session service is composed.
|
|
151
|
+
*/
|
|
152
|
+
protected sessionRoot(sessionId: string | undefined): Promise<string | undefined>;
|
|
153
|
+
}
|
|
154
|
+
export default DevflowStore;
|
|
155
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Definition of the `ctx.devflow` capability seam: file-backed task
|
|
3
|
+
* cards whose stage moves through a fixed pipeline. This package owns the card
|
|
4
|
+
* vocabulary and the journal decode/replay used by every consumer. Storage
|
|
5
|
+
* mechanics belong to a provider such as `@zhchxiao123/dsh-devflow-filesystem`;
|
|
6
|
+
* model-facing tools belong to `@zhchxiao123/dsh-devflow-tool`.
|
|
7
|
+
* @module @zhchxiao123/dsh-devflow
|
|
8
|
+
*/
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
11
|
+
export { DEV_STAGES, DevflowCardId, isCardLocation, isDevStage, isLegalTransition, isReworkEdge } from "./stages.js";
|
|
12
|
+
export { decodeJournalEntry, foldJournal } from "./journal.js";
|
|
13
|
+
/**
|
|
14
|
+
* Abstract task-card store registered as `ctx.devflow` (one implementation per
|
|
15
|
+
* context; loading a second throws, cordis' standard duplicate-service
|
|
16
|
+
* behavior). Subclass, implement the abstract methods, and load the subclass
|
|
17
|
+
* as a plugin.
|
|
18
|
+
*
|
|
19
|
+
* Implementations must honor these read-side semantics:
|
|
20
|
+
* - Current state comes from journal replay ({@link foldJournal}); the card
|
|
21
|
+
* file's frontmatter is a projection. On disagreement the journal wins and
|
|
22
|
+
* the drift is warned, never silently adopted.
|
|
23
|
+
* - A structurally invalid journal fails the read loudly, naming the file and
|
|
24
|
+
* line; a card is never silently skipped.
|
|
25
|
+
*/
|
|
26
|
+
export class DevflowStore extends Service {
|
|
27
|
+
constructor(ctx) {
|
|
28
|
+
super(ctx, 'devflow');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* {@link list} scoped to a viewing session's workspace, the face every
|
|
32
|
+
* browser channel reads through.
|
|
33
|
+
* @param filter - optional narrowing; omitted lists every card.
|
|
34
|
+
* @param sessionId - the viewing session; its workspace resolves host-side
|
|
35
|
+
* to the devflow root, so the wire never carries a file path. Omitted
|
|
36
|
+
* lists the default root.
|
|
37
|
+
* @returns cards ordered by id.
|
|
38
|
+
*/
|
|
39
|
+
async listForSession(filter, sessionId) {
|
|
40
|
+
return this.list(filter, await this.sessionRoot(sessionId));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* One card's detail scoped to a viewing session's workspace: the read value,
|
|
44
|
+
* its complete decoded journal, and the current lease holder in one round
|
|
45
|
+
* trip.
|
|
46
|
+
* @param id - the card id (its directory name).
|
|
47
|
+
* @param sessionId - the viewing session; resolved like {@link listForSession}.
|
|
48
|
+
* @returns the aggregated detail; `holder` is absent while the card is unclaimed.
|
|
49
|
+
*/
|
|
50
|
+
async detailForSession(id, sessionId) {
|
|
51
|
+
const root = await this.sessionRoot(sessionId);
|
|
52
|
+
// The card and its journal are two reads of one file; a transition landing
|
|
53
|
+
// between them would tear the aggregate (revisions are contiguous from 1,
|
|
54
|
+
// so the last entry's rev must equal the card's). One re-read absorbs the
|
|
55
|
+
// race; a still-moving card ships its newest pair and the next forwarded
|
|
56
|
+
// event refetches anyway.
|
|
57
|
+
let card = await this.read(id, root);
|
|
58
|
+
let entries = await this.history(id, root);
|
|
59
|
+
if (entries.at(-1)?.rev !== card.stageRevision) {
|
|
60
|
+
card = await this.read(id, root);
|
|
61
|
+
entries = await this.history(id, root);
|
|
62
|
+
}
|
|
63
|
+
const holder = await this.holder(id, root);
|
|
64
|
+
return { card, entries, ...holder === undefined ? {} : { holder } };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a viewing session into its workspace devflow root: the live or
|
|
68
|
+
* persisted session's header cwd maps to `<cwd>/.devflow`, and a session
|
|
69
|
+
* without a cwd derives no root (the implementation default applies). The
|
|
70
|
+
* browser sends only the session id — this host-side step is what keeps
|
|
71
|
+
* a root off the wire the browser can choose.
|
|
72
|
+
* @param sessionId - the viewing session, or `undefined` for the default root.
|
|
73
|
+
* @returns the derived root, or `undefined` when none derives.
|
|
74
|
+
* @throws {Error} for an unknown session, or when no session service is composed.
|
|
75
|
+
*/
|
|
76
|
+
async sessionRoot(sessionId) {
|
|
77
|
+
if (sessionId === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
// The wire delivers the id as a validated string; the brand is this
|
|
80
|
+
// process's own session vocabulary.
|
|
81
|
+
const id = sessionId;
|
|
82
|
+
const live = this.ctx.get('sessions')?.get(id);
|
|
83
|
+
if (live !== undefined)
|
|
84
|
+
return rootOfCwd(live.header.cwd);
|
|
85
|
+
const persistence = this.ctx.get('sessionPersistence');
|
|
86
|
+
if (persistence === undefined) {
|
|
87
|
+
throw new Error(`devflow: cannot resolve session ${sessionId}: no session service is composed`);
|
|
88
|
+
}
|
|
89
|
+
let cwd;
|
|
90
|
+
try {
|
|
91
|
+
cwd = (await persistence.inspect(id)).meta.cwd;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
throw new Error(`devflow: unknown session ${sessionId}`, { cause: error });
|
|
95
|
+
}
|
|
96
|
+
return rootOfCwd(cwd);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** The workspace's devflow root for a session cwd; no cwd derives no root. */
|
|
100
|
+
function rootOfCwd(cwd) {
|
|
101
|
+
return cwd === undefined ? undefined : join(cwd, '.devflow');
|
|
102
|
+
}
|
|
103
|
+
export default DevflowStore;
|
|
104
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Package-owned devflow event-stream invariants. @module @zhchxiao123/dsh-devflow/invariant */
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
3
|
+
/** Cordis companion plugin name. */
|
|
4
|
+
export declare const name = "devflow-invariant";
|
|
5
|
+
/** Service required before the companion can reserve package ownership. */
|
|
6
|
+
export declare const inject: string[];
|
|
7
|
+
/**
|
|
8
|
+
* Register this package's invariant companion.
|
|
9
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
10
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
11
|
+
*/
|
|
12
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
13
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Package-owned devflow event-stream invariants. @module @zhchxiao123/dsh-devflow/invariant */
|
|
2
|
+
const PACKAGE_NAME = '@zhchxiao123/dsh-devflow';
|
|
3
|
+
/** Cordis companion plugin name. */
|
|
4
|
+
export const name = 'devflow-invariant';
|
|
5
|
+
/** Service required before the companion can reserve package ownership. */
|
|
6
|
+
export const inject = ['invariants'];
|
|
7
|
+
/**
|
|
8
|
+
* Validate the devflow notification streams: `devflow/card-created` announces
|
|
9
|
+
* only fresh drafts at revision 1 for ids the stream has never seen and never
|
|
10
|
+
* nests a breakdown two levels deep, and per card, `devflow/stage-changed`
|
|
11
|
+
* revisions strictly increase while every notification reports an actual move.
|
|
12
|
+
*/
|
|
13
|
+
const install = (ctx, fail) => {
|
|
14
|
+
const lastRevision = new Map();
|
|
15
|
+
const children = new Set();
|
|
16
|
+
// Cards from different roots may share an id; the stream relations hold
|
|
17
|
+
// per root + id, the same key every store and driver book-keeping uses.
|
|
18
|
+
const key = (card) => `${card.root} ${card.id}`;
|
|
19
|
+
ctx.on('devflow/card-created', (card) => {
|
|
20
|
+
if (card.stage !== 'draft' || card.stageRevision !== 1) {
|
|
21
|
+
fail(`devflow/card-created for card ${card.id} reports "${card.stage}" at rev ${card.stageRevision}; a card must enter the board as "draft" at revision 1`);
|
|
22
|
+
}
|
|
23
|
+
if (lastRevision.has(key(card))) {
|
|
24
|
+
fail(`devflow/card-created repeats card ${card.id} of root ${card.root}; an id is never reissued`);
|
|
25
|
+
}
|
|
26
|
+
if (card.parent !== undefined) {
|
|
27
|
+
if (children.has(`${card.root} ${card.parent}`)) {
|
|
28
|
+
fail(`devflow/card-created hangs card ${card.id} under ${card.parent}, which is itself a child; the breakdown is one level deep`);
|
|
29
|
+
}
|
|
30
|
+
children.add(key(card));
|
|
31
|
+
}
|
|
32
|
+
lastRevision.set(key(card), card.stageRevision);
|
|
33
|
+
}, { global: true });
|
|
34
|
+
ctx.on('devflow/stage-changed', (card, from) => {
|
|
35
|
+
if (card.stage === from) {
|
|
36
|
+
fail(`devflow/stage-changed for card ${card.id} reports no move (still at "${from}")`);
|
|
37
|
+
}
|
|
38
|
+
const previous = lastRevision.get(key(card));
|
|
39
|
+
if (previous !== undefined && card.stageRevision <= previous) {
|
|
40
|
+
fail(`devflow/stage-changed for card ${card.id} carries rev ${card.stageRevision} after rev ${previous}; revisions must strictly increase`);
|
|
41
|
+
}
|
|
42
|
+
lastRevision.set(key(card), card.stageRevision);
|
|
43
|
+
}, { global: true });
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Register this package's invariant companion.
|
|
47
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
48
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
49
|
+
*/
|
|
50
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
51
|
+
//# sourceMappingURL=invariant.js.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal decoding and replay for the devflow seam. The journal is the
|
|
3
|
+
* authoritative card history; the card file's frontmatter is a rebuildable
|
|
4
|
+
* projection. Both the filesystem provider and the invariant companion fold
|
|
5
|
+
* entries through this module so every consumer derives identical state.
|
|
6
|
+
* @module @zhchxiao123/dsh-devflow/src/journal
|
|
7
|
+
*/
|
|
8
|
+
import { DevflowCardId } from './stages.ts';
|
|
9
|
+
import type { CardLocation, DevStage, DevflowJournalEntry } from './types.ts';
|
|
10
|
+
/** Card state derived by {@link foldJournal}; the read-side authority. */
|
|
11
|
+
export interface JournalFoldState {
|
|
12
|
+
/** Current location after the last entry. */
|
|
13
|
+
stage: CardLocation;
|
|
14
|
+
/** Revision of the last entry; the optimistic-concurrency token. */
|
|
15
|
+
revision: number;
|
|
16
|
+
/** The stage a blocked card returns to; present exactly while `stage` is `blocked`. */
|
|
17
|
+
blockedFrom?: DevStage;
|
|
18
|
+
/** The card this one decomposes, from the `created` entry; absent for a top-level card. */
|
|
19
|
+
parent?: DevflowCardId;
|
|
20
|
+
/** Artifact paths in registration order. */
|
|
21
|
+
artifacts: string[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Decode one parsed journal value into a {@link DevflowJournalEntry}.
|
|
25
|
+
*
|
|
26
|
+
* This is the durable-boundary validator: journal lines come from a file that
|
|
27
|
+
* humans and other processes may write, so every field is checked and a bad
|
|
28
|
+
* entry throws instead of being skipped.
|
|
29
|
+
* @param value - one JSON-parsed journal line.
|
|
30
|
+
* @returns the validated entry.
|
|
31
|
+
* @throws {Error} naming the first violated field.
|
|
32
|
+
*/
|
|
33
|
+
export declare function decodeJournalEntry(value: unknown): DevflowJournalEntry;
|
|
34
|
+
/**
|
|
35
|
+
* Replay a complete journal into the card's current state.
|
|
36
|
+
*
|
|
37
|
+
* Validates the structural invariants of the durable stream: revisions are the
|
|
38
|
+
* contiguous sequence 1..n, the first entry is `created`, every transition
|
|
39
|
+
* departs from the current location, a move to `blocked` remembers its origin,
|
|
40
|
+
* and the matching recovery returns exactly there.
|
|
41
|
+
* @param entries - decoded entries in file order.
|
|
42
|
+
* @returns the folded card state.
|
|
43
|
+
* @throws {Error} naming the first violated invariant and its entry revision.
|
|
44
|
+
*/
|
|
45
|
+
export declare function foldJournal(entries: readonly DevflowJournalEntry[]): JournalFoldState;
|
|
46
|
+
//# sourceMappingURL=journal.d.ts.map
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal decoding and replay for the devflow seam. The journal is the
|
|
3
|
+
* authoritative card history; the card file's frontmatter is a rebuildable
|
|
4
|
+
* projection. Both the filesystem provider and the invariant companion fold
|
|
5
|
+
* entries through this module so every consumer derives identical state.
|
|
6
|
+
* @module @zhchxiao123/dsh-devflow/src/journal
|
|
7
|
+
*/
|
|
8
|
+
import { DEV_STAGES, DevflowCardId, isCardLocation, isDevStage } from "./stages.js";
|
|
9
|
+
/**
|
|
10
|
+
* Decode one parsed journal value into a {@link DevflowJournalEntry}.
|
|
11
|
+
*
|
|
12
|
+
* This is the durable-boundary validator: journal lines come from a file that
|
|
13
|
+
* humans and other processes may write, so every field is checked and a bad
|
|
14
|
+
* entry throws instead of being skipped.
|
|
15
|
+
* @param value - one JSON-parsed journal line.
|
|
16
|
+
* @returns the validated entry.
|
|
17
|
+
* @throws {Error} naming the first violated field.
|
|
18
|
+
*/
|
|
19
|
+
export function decodeJournalEntry(value) {
|
|
20
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
21
|
+
throw new Error('journal entry must be a JSON object');
|
|
22
|
+
}
|
|
23
|
+
const entry = value;
|
|
24
|
+
const rev = entry.rev;
|
|
25
|
+
if (typeof rev !== 'number' || !Number.isInteger(rev) || rev < 1) {
|
|
26
|
+
throw new Error('journal entry field "rev" must be a positive integer');
|
|
27
|
+
}
|
|
28
|
+
if (typeof entry.at !== 'string' || entry.at.length === 0) {
|
|
29
|
+
throw new Error('journal entry field "at" must be a non-empty string');
|
|
30
|
+
}
|
|
31
|
+
switch (entry.type) {
|
|
32
|
+
case 'created':
|
|
33
|
+
return {
|
|
34
|
+
rev,
|
|
35
|
+
at: entry.at,
|
|
36
|
+
type: 'created',
|
|
37
|
+
by: decodeActor(entry.by),
|
|
38
|
+
...decodeOptionalCardId(entry, 'parent'),
|
|
39
|
+
};
|
|
40
|
+
case 'transition': {
|
|
41
|
+
if (!isCardLocation(entry.from))
|
|
42
|
+
throw new Error('transition field "from" must be a stage or "blocked"');
|
|
43
|
+
if (!isCardLocation(entry.to))
|
|
44
|
+
throw new Error('transition field "to" must be a stage or "blocked"');
|
|
45
|
+
return {
|
|
46
|
+
rev,
|
|
47
|
+
at: entry.at,
|
|
48
|
+
type: 'transition',
|
|
49
|
+
from: entry.from,
|
|
50
|
+
to: entry.to,
|
|
51
|
+
...entry.by !== undefined ? { by: decodeActor(entry.by) } : {},
|
|
52
|
+
...decodeOptionalString(entry, 'reason'),
|
|
53
|
+
...entry.gate !== undefined ? { gate: decodeGate(entry.gate) } : {},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
case 'artifact': {
|
|
57
|
+
if (typeof entry.path !== 'string' || entry.path.length === 0) {
|
|
58
|
+
throw new Error('artifact field "path" must be a non-empty string');
|
|
59
|
+
}
|
|
60
|
+
if (!isDevStage(entry.stage)) {
|
|
61
|
+
throw new Error(`artifact field "stage" must be one of ${DEV_STAGES.join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
rev,
|
|
65
|
+
at: entry.at,
|
|
66
|
+
type: 'artifact',
|
|
67
|
+
path: entry.path,
|
|
68
|
+
stage: entry.stage,
|
|
69
|
+
...entry.by !== undefined ? { by: decodeActor(entry.by) } : {},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
case 'claim-expired': {
|
|
73
|
+
if (entry.previousOwner === undefined) {
|
|
74
|
+
throw new Error('claim-expired field "previousOwner" is required');
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
rev,
|
|
78
|
+
at: entry.at,
|
|
79
|
+
type: 'claim-expired',
|
|
80
|
+
previousOwner: decodeActor(entry.previousOwner),
|
|
81
|
+
by: decodeActor(entry.by),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
throw new Error(`journal entry field "type" must be created, transition, artifact, or claim-expired (got ${JSON.stringify(entry.type)})`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Replay a complete journal into the card's current state.
|
|
90
|
+
*
|
|
91
|
+
* Validates the structural invariants of the durable stream: revisions are the
|
|
92
|
+
* contiguous sequence 1..n, the first entry is `created`, every transition
|
|
93
|
+
* departs from the current location, a move to `blocked` remembers its origin,
|
|
94
|
+
* and the matching recovery returns exactly there.
|
|
95
|
+
* @param entries - decoded entries in file order.
|
|
96
|
+
* @returns the folded card state.
|
|
97
|
+
* @throws {Error} naming the first violated invariant and its entry revision.
|
|
98
|
+
*/
|
|
99
|
+
export function foldJournal(entries) {
|
|
100
|
+
if (entries.length === 0)
|
|
101
|
+
throw new Error('journal is empty; every card starts with a "created" entry');
|
|
102
|
+
const state = { stage: 'draft', revision: 0, artifacts: [] };
|
|
103
|
+
for (const [index, entry] of entries.entries()) {
|
|
104
|
+
if (entry.rev !== index + 1) {
|
|
105
|
+
throw new Error(`journal entry ${index + 1} carries rev ${entry.rev}; revisions must be contiguous from 1`);
|
|
106
|
+
}
|
|
107
|
+
if (index === 0) {
|
|
108
|
+
if (entry.type !== 'created')
|
|
109
|
+
throw new Error('journal entry 1 must be "created"');
|
|
110
|
+
if (entry.parent !== undefined)
|
|
111
|
+
state.parent = entry.parent;
|
|
112
|
+
state.revision = entry.rev;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
switch (entry.type) {
|
|
116
|
+
case 'created':
|
|
117
|
+
throw new Error(`journal entry rev ${entry.rev} repeats "created"`);
|
|
118
|
+
case 'transition': {
|
|
119
|
+
if (entry.from !== state.stage) {
|
|
120
|
+
throw new Error(`transition rev ${entry.rev} departs from "${entry.from}" but the card is at "${state.stage}"`);
|
|
121
|
+
}
|
|
122
|
+
if (entry.to === state.stage) {
|
|
123
|
+
throw new Error(`transition rev ${entry.rev} does not move the card (already at "${entry.to}")`);
|
|
124
|
+
}
|
|
125
|
+
if (entry.to === 'blocked') {
|
|
126
|
+
// `from` is a stage here: the departure check above matched the
|
|
127
|
+
// current location, and a blocked card cannot block again.
|
|
128
|
+
state.blockedFrom = entry.from;
|
|
129
|
+
}
|
|
130
|
+
else if (state.stage === 'blocked') {
|
|
131
|
+
if (entry.to !== state.blockedFrom) {
|
|
132
|
+
throw new Error(`transition rev ${entry.rev} recovers to "${entry.to}" but the card blocked from "${state.blockedFrom}"`);
|
|
133
|
+
}
|
|
134
|
+
delete state.blockedFrom;
|
|
135
|
+
}
|
|
136
|
+
state.stage = entry.to;
|
|
137
|
+
state.revision = entry.rev;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case 'artifact':
|
|
141
|
+
state.artifacts.push(entry.path);
|
|
142
|
+
state.revision = entry.rev;
|
|
143
|
+
break;
|
|
144
|
+
case 'claim-expired':
|
|
145
|
+
state.revision = entry.rev;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return state;
|
|
150
|
+
}
|
|
151
|
+
function decodeGate(value) {
|
|
152
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
153
|
+
throw new Error('transition field "gate" must be a JSON object');
|
|
154
|
+
}
|
|
155
|
+
const gate = value;
|
|
156
|
+
if (gate.approvedBy === undefined) {
|
|
157
|
+
throw new Error('transition field "gate" requires "approvedBy"');
|
|
158
|
+
}
|
|
159
|
+
return { approvedBy: decodeActor(gate.approvedBy) };
|
|
160
|
+
}
|
|
161
|
+
function decodeActor(value) {
|
|
162
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
163
|
+
throw new Error('actor must be a JSON object');
|
|
164
|
+
}
|
|
165
|
+
const actor = value;
|
|
166
|
+
switch (actor.kind) {
|
|
167
|
+
case 'human':
|
|
168
|
+
return { kind: 'human', ...decodeOptionalString(actor, 'name') };
|
|
169
|
+
case 'agent':
|
|
170
|
+
return { kind: 'agent', ...decodeOptionalString(actor, 'session') };
|
|
171
|
+
case 'command':
|
|
172
|
+
return { kind: 'command', ...decodeOptionalString(actor, 'name') };
|
|
173
|
+
default:
|
|
174
|
+
throw new Error(`actor field "kind" must be human, agent, or command (got ${JSON.stringify(actor.kind)})`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function decodeOptionalCardId(record, key) {
|
|
178
|
+
const value = record[key];
|
|
179
|
+
if (value === undefined)
|
|
180
|
+
return {};
|
|
181
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
182
|
+
throw new Error(`field "${key}" must be a non-empty card id when present`);
|
|
183
|
+
}
|
|
184
|
+
return { [key]: DevflowCardId(value) };
|
|
185
|
+
}
|
|
186
|
+
function decodeOptionalString(record, key) {
|
|
187
|
+
const value = record[key];
|
|
188
|
+
if (value === undefined)
|
|
189
|
+
return {};
|
|
190
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
191
|
+
throw new Error(`field "${key}" must be a non-empty string when present`);
|
|
192
|
+
}
|
|
193
|
+
return { [key]: value };
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=journal.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime stage vocabulary: the ordered stage list, location narrowing, and the
|
|
3
|
+
* card-id factory. Kept beside the type-only module so `types.ts` stays free of
|
|
4
|
+
* runtime code.
|
|
5
|
+
* @module @zhchxiao123/dsh-devflow/src/stages
|
|
6
|
+
*/
|
|
7
|
+
import type { Branded } from '@deepseek-ai/dsh-brand';
|
|
8
|
+
import type { CardLocation, DevStage } from './types.ts';
|
|
9
|
+
/** Opaque id of one task card; equals the card's directory name and never changes. */
|
|
10
|
+
export type DevflowCardId = Branded<'DevflowCardId'>;
|
|
11
|
+
/** The pipeline stages in flow order; `blocked` is a bypass, not a member. */
|
|
12
|
+
export declare const DEV_STAGES: readonly ["draft", "designing", "ready", "developing", "reviewing", "testing", "done"];
|
|
13
|
+
/**
|
|
14
|
+
* Narrow an unknown value to a pipeline stage.
|
|
15
|
+
* @param value - the candidate value.
|
|
16
|
+
* @returns `true` when `value` is one of {@link DEV_STAGES}.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isDevStage(value: unknown): value is DevStage;
|
|
19
|
+
/**
|
|
20
|
+
* Narrow an unknown value to a card location (a stage or `blocked`).
|
|
21
|
+
* @param value - the candidate value.
|
|
22
|
+
* @returns `true` when `value` is a stage or the `blocked` bypass.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isCardLocation(value: unknown): value is CardLocation;
|
|
25
|
+
/**
|
|
26
|
+
* Brand a raw string as a {@link DevflowCardId}. The id equals the card's
|
|
27
|
+
* directory name; construction lives here because this package owns the brand.
|
|
28
|
+
* @param value - the card directory name.
|
|
29
|
+
* @returns the branded id.
|
|
30
|
+
*/
|
|
31
|
+
export declare function DevflowCardId(value: string): DevflowCardId;
|
|
32
|
+
/**
|
|
33
|
+
* Whether one stage move is a legal edge of the state machine.
|
|
34
|
+
*
|
|
35
|
+
* Main flow follows the pipeline order; `reviewing` and `testing` may rework
|
|
36
|
+
* to `developing`; any non-terminal location may enter `blocked`; a blocked
|
|
37
|
+
* card may only recover to the exact stage it interrupted.
|
|
38
|
+
* @param from - the card's current location.
|
|
39
|
+
* @param to - the requested target location.
|
|
40
|
+
* @param blockedFrom - the remembered origin stage while `from` is `blocked`.
|
|
41
|
+
* @returns `true` when the move is a legal edge.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isLegalTransition(from: CardLocation, to: CardLocation, blockedFrom?: DevStage): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Whether a legal edge moves the card backwards (a rework). Rework edges
|
|
46
|
+
* require a recorded `reason` so the next holder knows what to fix.
|
|
47
|
+
* @param from - the departing location.
|
|
48
|
+
* @param to - the target location.
|
|
49
|
+
* @returns `true` for `reviewing -> developing` and `testing -> developing`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function isReworkEdge(from: CardLocation, to: CardLocation): boolean;
|
|
52
|
+
//# sourceMappingURL=stages.d.ts.map
|