Class: Woods::IndexArtifact

Inherits:
Object
  • Object
show all
Defined in:
lib/woods/index_artifact.rb

Overview

Whole Value for the on-disk artifact layout under output_dir.

Centralises all path derivation and atomic write operations so that no caller ever assembles paths by hand. The dumps/latest pointer file provides cross-artifact atomicity: consumers always read the pointer first; the pointer is flipped last, after the dump directory is fully fsynced.

Examples:

Basic usage

artifact = Woods::IndexArtifact.new(output_dir)
return if artifact.fresh?
config = artifact.read_config
dump_dir = artifact.latest_dump_path

Instance Method Summary collapse

Constructor Details

#initialize(output_dir) ⇒ IndexArtifact

Returns a new instance of IndexArtifact.

Parameters:

  • output_dir (String, Pathname)

    path to the extraction output directory



25
26
27
# File 'lib/woods/index_artifact.rb', line 25

def initialize(output_dir)
  @root = Pathname.new(output_dir.to_s)
end

Instance Method Details

#config_pathPathname

Path to the resolved config snapshot written by the embed run.

Returns:

  • (Pathname)


39
40
41
# File 'lib/woods/index_artifact.rb', line 39

def config_path
  @root.join('woods.json')
end

#dumps_rootPathname

Root of the per-run dump directories.

Returns:

  • (Pathname)


46
47
48
# File 'lib/woods/index_artifact.rb', line 46

def dumps_root
  @root.join('dumps')
end

#fresh?Boolean

Returns true when the artifact has never been populated — woods.json is absent AND the dumps/latest pointer does not exist.

Once either file is present the artifact is considered non-fresh; the Bootstrapper uses this to decide whether to raise MCP::MissingArtifact or proceed with loading.

Returns:

  • (Boolean)


65
66
67
# File 'lib/woods/index_artifact.rb', line 65

def fresh?
  !config_path.exist? && !latest_pointer_path.exist?
end

#latest_dump_pathPathname?

Path to the latest complete dump directory, or nil.

Returns nil if the pointer file does not exist, if the pointer content is blank, or if the directory it names no longer exists (stale pointer).

Returns:

  • (Pathname, nil)


75
76
77
78
79
80
81
82
83
# File 'lib/woods/index_artifact.rb', line 75

def latest_dump_path
  return nil unless latest_pointer_path.exist?

  dirname = latest_pointer_path.read.strip
  return nil if dirname.empty?

  dir = dumps_root.join(dirname)
  dir.exist? ? dir : nil
end

#latest_pointer_pathPathname

Path to the dumps/latest pointer file (may not exist yet).

Returns:

  • (Pathname)


53
54
55
# File 'lib/woods/index_artifact.rb', line 53

def latest_pointer_path
  dumps_root.join('latest')
end

#new_dump_dir(now: Time.now.utc) ⇒ Pathname

Creates a new timestamped dump directory and returns its path.

The directory is created immediately so callers can begin writing into it. dumps_root is created on demand. Directory names use dashes in place of colons for filesystem compatibility (+%H-%M-%SZ+ not %H:%M:%SZ).

Parameters:

  • now (Time) (defaults to: Time.now.utc)

    timestamp to use for the directory name (default: UTC now)

Returns:

  • (Pathname)

Raises:

  • (Errno::EEXIST)

    if the target directory already exists



107
108
109
110
111
112
113
# File 'lib/woods/index_artifact.rb', line 107

def new_dump_dir(now: Time.now.utc)
  dirname = now.strftime('%Y-%m-%dT%H-%M-%SZ')
  dir = dumps_root.join(dirname)
  FileUtils.mkdir_p(dumps_root)
  Dir.mkdir(dir.to_s)
  dir
end

#output_dirPathname

The extraction output directory root.

Returns:

  • (Pathname)


32
33
34
# File 'lib/woods/index_artifact.rb', line 32

def output_dir
  @root
end

#promote(dump_dir) ⇒ void

This method returns an undefined value.

Atomically flips the latest pointer to the given dump directory.

Uses a temp file + File.rename so a crash mid-flip leaves the previous pointer intact. dump_dir must exist and resolve to a path inside dumps_root.

Parameters:

  • dump_dir (Pathname, String)

    completed dump directory to promote

Raises:

  • (ArgumentError)

    if dump_dir does not exist or is outside dumps_root



124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/woods/index_artifact.rb', line 124

def promote(dump_dir)
  target = Pathname.new(dump_dir.to_s)
  root_real = dumps_root.expand_path.to_s
  target_real = target.exist? ? target.realpath.to_s : ''
  # Resolve symlinks on both sides before comparing (handles macOS /tmp → /private/var)
  root_resolved = Pathname.new(root_real).exist? ? Pathname.new(root_real).realpath.to_s : root_real
  unless target.exist? && target_real.start_with?(root_resolved)
    raise ArgumentError,
          'dump_dir must exist inside dumps_root. ' \
          "Got: #{dump_dir.inspect}, dumps_root: #{dumps_root}"
  end

  atomic_write(latest_pointer_path, target.basename.to_s)
end

#read_configHash?

Reads and parses woods.json, returning the raw hash.

Returns nil when the file does not exist. Schema-version validation is the caller’s responsibility (typically ResolvedConfig.from_hash).

Returns:

  • (Hash, nil)


91
92
93
94
95
# File 'lib/woods/index_artifact.rb', line 91

def read_config
  return nil unless config_path.exist?

  JSON.parse(config_path.read)
end

#write_config(resolved_config_hash) ⇒ void

This method returns an undefined value.

Atomically writes a resolved config hash as woods.json.

Accepts either a ResolvedConfig (responds to #to_snapshot_json) or a plain Hash. When #to_snapshot_json returns a Hash, it is serialized to JSON automatically — callers need not pre-serialize.

Parameters:

  • resolved_config_hash (#to_snapshot_json, Hash)


147
148
149
150
151
152
153
154
155
# File 'lib/woods/index_artifact.rb', line 147

def write_config(resolved_config_hash)
  raw = if resolved_config_hash.respond_to?(:to_snapshot_json)
          resolved_config_hash.to_snapshot_json
        else
          resolved_config_hash
        end
  json = raw.is_a?(String) ? raw : JSON.pretty_generate(raw)
  atomic_write(config_path, json)
end